From 0b79d0fe03dd410e6e4d03cb16d4999a2b35b330 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 15:28:56 -0400 Subject: [PATCH 01/35] fix: authenticate inbound LINE media before decryption Adapt https://github.com/beeper/line/pull/196 (a6d06b0cde098719ac78ed9dd673bfa7244cea17) to the shared media decryptor, covering both uploads and direct media. Preserve upstream fail-closed key fallback and route video to chunk-hash HMAC verification. Add valid, tampered and wrong-mode coverage for all media kinds. Co-authored-by: Adrien Vermersch --- pkg/connector/handle_message.go | 11 +++--- pkg/connector/handlers/handler.go | 10 ++++-- pkg/connector/handlers/handler_test.go | 2 +- pkg/connector/media.go | 25 +++++++++++--- pkg/connector/media_hmac_test.go | 48 ++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 pkg/connector/media_hmac_test.go diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 10e1c03c..633c2021 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -29,11 +29,12 @@ const ( func (lc *LineClient) newMessageHandler() *handlers.Handler { handler := &handlers.Handler{ - Log: lc.UserLogin.Bridge.Log, - HTTPClient: lc.HTTPClient, - RecoverClient: lc.recoverClientAfterAuthError, - NewClient: func() *line.Client { return lc.newClient() }, - DecryptMedia: lc.decryptImageData, + Log: lc.UserLogin.Bridge.Log, + HTTPClient: lc.HTTPClient, + RecoverClient: lc.recoverClientAfterAuthError, + NewClient: func() *line.Client { return lc.newClient() }, + DecryptMedia: lc.decryptImageData, + DecryptVideoMedia: lc.decryptVideoData, } connector, ok := lc.UserLogin.Bridge.Network.(*LineConnector) if ok && connector.directMedia.Load() { diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 04c4dd99..8d8542e3 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -40,6 +40,8 @@ type Handler struct { // DecryptMedia decrypts E2EE encrypted media data using the given key material. DecryptMedia func(data []byte, keyMaterial string) ([]byte, error) + // DecryptVideoMedia verifies the video-specific chunk-hash HMAC before decryption. + DecryptVideoMedia func(data []byte, keyMaterial string) ([]byte, error) } func (h *Handler) decryptDownloadedMedia(data []byte, decryptedBody string, metadata map[string]string, kind string) ([]byte, error) { @@ -90,13 +92,17 @@ func (h *Handler) decryptMediaWithKeys(data []byte, keys []string, encrypted boo if len(keys) == 0 { return nil, fmt.Errorf("%w: encrypted %s has no usable media key", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind)) } - if h.DecryptMedia == nil { + decrypt := h.DecryptMedia + if kind == "video" { + decrypt = h.DecryptVideoMedia + } + if decrypt == nil { return nil, fmt.Errorf("%w: encrypted %s has no media decryptor", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind)) } decryptErrors := make([]error, 0, len(keys)) for _, key := range keys { - decrypted, err := h.DecryptMedia(data, key) + decrypted, err := decrypt(data, key) if err == nil { return decrypted, nil } diff --git a/pkg/connector/handlers/handler_test.go b/pkg/connector/handlers/handler_test.go index 19057e03..c825b11d 100644 --- a/pkg/connector/handlers/handler_test.go +++ b/pkg/connector/handlers/handler_test.go @@ -88,7 +88,7 @@ func TestDecryptDownloadedMediaFailsClosed(t *testing.T) { ciphertext := []byte("ciphertext") decryptErr := errors.New("invalid media key") var calls int - h := &Handler{DecryptMedia: func([]byte, string) ([]byte, error) { + h := &Handler{DecryptVideoMedia: func([]byte, string) ([]byte, error) { calls++ return nil, decryptErr }} diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 482236d0..b91fee16 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -144,6 +144,16 @@ func stripJPEGMetadata(data []byte) []byte { // LINE's E2EE file format: [encrypted_data][32-byte HMAC] // The keyMaterial is derived using HKDF to get encKey (32), macKey (32), and nonce (12 bytes) func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 string) ([]byte, error) { + return lc.decryptMediaData(encryptedData, keyMaterialB64, func(ciphertext []byte) []byte { + return ciphertext + }) +} + +func (lc *LineClient) decryptVideoData(encryptedData []byte, keyMaterialB64 string) ([]byte, error) { + return lc.decryptMediaData(encryptedData, keyMaterialB64, generateChunkHashes) +} + +func (lc *LineClient) decryptMediaData(encryptedData []byte, keyMaterialB64 string, macInput func([]byte) []byte) ([]byte, error) { keyMaterial, err := base64.StdEncoding.DecodeString(keyMaterialB64) if err != nil { return nil, fmt.Errorf("failed to decode key material: %w", err) @@ -158,7 +168,7 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri } encKey := derived[0:32] - // macKey := derived[32:64] // for HMAC verification + macKey := derived[32:64] nonce := derived[64:76] // Create 16-byte counter: nonce(12 bytes) + zero counter(4 bytes) @@ -169,7 +179,14 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri if len(encryptedData) < 32 { return nil, fmt.Errorf("encrypted data too short (< 32 bytes for HMAC)") } - encryptedData = encryptedData[:len(encryptedData)-32] + ciphertext := encryptedData[:len(encryptedData)-32] + receivedMAC := encryptedData[len(encryptedData)-32:] + + h := hmac.New(sha256.New, macKey) + h.Write(macInput(ciphertext)) + if !hmac.Equal(receivedMAC, h.Sum(nil)) { + return nil, fmt.Errorf("media HMAC verification failed") + } block, err := aes.NewCipher(encKey) if err != nil { @@ -178,8 +195,8 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri stream := cipher.NewCTR(block, counter) - decrypted := make([]byte, len(encryptedData)) - stream.XORKeyStream(decrypted, encryptedData) + decrypted := make([]byte, len(ciphertext)) + stream.XORKeyStream(decrypted, ciphertext) return decrypted, nil } diff --git a/pkg/connector/media_hmac_test.go b/pkg/connector/media_hmac_test.go new file mode 100644 index 00000000..57fa7425 --- /dev/null +++ b/pkg/connector/media_hmac_test.go @@ -0,0 +1,48 @@ +package connector + +import ( + "bytes" + "testing" +) + +// Adapted from beeper/line#196. +func TestMediaHMAC(t *testing.T) { + lc := &LineClient{} + for _, kind := range []string{"image", "file", "audio", "video", "thumbnail"} { + t.Run(kind, func(t *testing.T) { + plaintext := bytes.Repeat([]byte("authenticated media"), 40000) + encrypt := lc.encryptFileData + if kind == "video" { + encrypt = lc.encryptVideoData + } + ciphertext, key, err := encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + if kind == "thumbnail" { + ciphertext, err = encryptThumbnail(plaintext, key) + if err != nil { + t.Fatal(err) + } + } + decrypt, wrongMode := lc.decryptImageData, lc.decryptVideoData + if kind == "video" { + decrypt, wrongMode = wrongMode, decrypt + } + decrypted, err := decrypt(ciphertext, key) + if err != nil || !bytes.Equal(decrypted, plaintext) { + t.Fatalf("valid media: err=%v, equal=%v", err, bytes.Equal(decrypted, plaintext)) + } + for _, offset := range []int{0, len(ciphertext) - 1} { + tampered := bytes.Clone(ciphertext) + tampered[offset] ^= 1 + if got, err := decrypt(tampered, key); err == nil || got != nil { + t.Fatalf("tampering at %d returned plaintext or no error", offset) + } + } + if got, err := wrongMode(ciphertext, key); err == nil || got != nil { + t.Fatal("accepted incorrect HMAC mode") + } + }) + } +} From 17ae6dcad91f89b255d16fce67789041dc792605 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 15:30:16 -0400 Subject: [PATCH 02/35] fix: attach detected metadata to inbound LINE images Select the image metadata changes from https://github.com/beeper/line/pull/189 at a4d8c11884a88527e5dbb50c80f7c1dd52b8ad64, retaining current download, size-limit and authentication handling. Keep the separate 60-retry OBS policy out of this scoped fix. Includes bounded ISO-BMFF brand detection and regression tests. Co-authored-by: Adrien Vermersch --- pkg/connector/handlers/image.go | 12 ++- pkg/connector/handlers/image_media.go | 98 ++++++++++++++++++++++ pkg/connector/handlers/image_media_test.go | 70 ++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 pkg/connector/handlers/image_media.go create mode 100644 pkg/connector/handlers/image_media_test.go diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 7c3ccdbd..ac3c41f7 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -112,8 +112,15 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } // Upload to Matrix + imageMedia := lineImageMediaInfo(imgData) + if imageMedia.usedMimeFallback { + h.Log.Debug().Int("size_bytes", len(imgData)).Msg("Falling back to JPEG MIME type for LINE image") + } + if imageMedia.decodeErr != nil { + h.Log.Debug().Err(imageMedia.decodeErr).Str("mime_type", imageMedia.mimeType).Msg("Could not decode LINE image dimensions") + } uploadStart := time.Now() - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, imageMedia.fileName, imageMedia.mimeType) uploadDuration := time.Since(uploadStart) if err != nil { h.Log.Error(). @@ -145,7 +152,8 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgImage, - Body: "image.jpg", + Body: imageMedia.fileName, + Info: imageMedia.info, URL: mxc, File: file, RelatesTo: relatesTo, diff --git a/pkg/connector/handlers/image_media.go b/pkg/connector/handlers/image_media.go new file mode 100644 index 00000000..74bd4975 --- /dev/null +++ b/pkg/connector/handlers/image_media.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "bytes" + "encoding/binary" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "net/http" + "strings" + + _ "golang.org/x/image/webp" + "maunium.net/go/mautrix/event" +) + +// Adapted from beeper/line#189; OBS retry policy remains independent. +type lineImageMedia struct { + fileName string + mimeType string + info *event.FileInfo + usedMimeFallback bool + decodeErr error +} + +func lineImageMediaInfo(data []byte) lineImageMedia { + mimeType, usedFallback := detectLineImageMimeType(data) + info := &event.FileInfo{MimeType: mimeType, Size: len(data)} + var decodeErr error + if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + info.Width = config.Width + info.Height = config.Height + } else if !usedFallback { + switch mimeType { + case "image/jpeg", "image/png", "image/gif", "image/webp": + decodeErr = err + } + } + return lineImageMedia{ + fileName: "image." + imageExtensionForMIME(mimeType), mimeType: mimeType, + info: info, usedMimeFallback: usedFallback, decodeErr: decodeErr, + } +} + +func detectLineImageMimeType(data []byte) (string, bool) { + mimeType := normalizedImageMIMEType(http.DetectContentType(data)) + if strings.HasPrefix(mimeType, "image/") { + return mimeType, false + } + if len(data) >= 12 && lineImageBrandAt(data, 4) == [4]byte{'f', 't', 'y', 'p'} { + boxSize := binary.BigEndian.Uint32(data[:4]) + if boxSize >= 16 && uint64(boxSize) <= uint64(len(data)) { + detected := "" + for offset, end := 8, int(boxSize); offset+4 <= end; { + switch lineImageBrandAt(data, offset) { + case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: + return "image/avif", false + case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: + return "image/heic", false + case [4]byte{'h', 'e', 'i', 'f'}, [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: + detected = "image/heif" + } + if offset == 8 { + offset = 16 + } else { + offset += 4 + } + } + if detected != "" { + return detected, false + } + } + } + return "image/jpeg", true +} + +func lineImageBrandAt(data []byte, offset int) [4]byte { + return [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} +} + +func imageExtensionForMIME(mimeType string) string { + switch normalizedMIMEType := normalizedImageMIMEType(mimeType); normalizedMIMEType { + case "image/jpeg": + return "jpg" + case "image/svg+xml": + return "svg" + case "image/x-icon", "image/vnd.microsoft.icon": + return "ico" + case "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif", "image/bmp", "image/tiff": + return strings.TrimPrefix(normalizedMIMEType, "image/") + } + return "jpg" +} + +func normalizedImageMIMEType(mimeType string) string { + normalizedMIMEType, _, _ := strings.Cut(mimeType, ";") + return strings.ToLower(strings.TrimSpace(normalizedMIMEType)) +} diff --git a/pkg/connector/handlers/image_media_test.go b/pkg/connector/handlers/image_media_test.go new file mode 100644 index 00000000..cc831ead --- /dev/null +++ b/pkg/connector/handlers/image_media_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "bytes" + "encoding/binary" + "image" + "image/jpeg" + "image/png" + "testing" +) + +func TestLineImageMediaInfo(t *testing.T) { + for _, format := range []string{"jpeg", "png"} { + t.Run(format, func(t *testing.T) { + var buf bytes.Buffer + img := image.NewRGBA(image.Rect(0, 0, 23, 17)) + var err error + if format == "jpeg" { + err = jpeg.Encode(&buf, img, nil) + } else { + err = png.Encode(&buf, img) + } + if err != nil { + t.Fatal(err) + } + got := lineImageMediaInfo(buf.Bytes()) + if got.mimeType != "image/"+format || got.info.Width != 23 || got.info.Height != 17 || got.info.Size != buf.Len() || got.info.MimeType != got.mimeType || got.usedMimeFallback || got.decodeErr != nil { + t.Fatalf("incorrect image metadata: %+v, info=%+v", got, got.info) + } + }) + } +} + +func TestLineImageBrands(t *testing.T) { + for _, tt := range []struct{ major, compatible, want string }{ + {"heic", "", "image/heic"}, {"heif", "", "image/heif"}, + {"mif1", "heic", "image/heic"}, {"mif1", "avif", "image/avif"}, + {"avif", "", "image/avif"}, + } { + t.Run(tt.major+tt.compatible, func(t *testing.T) { + data := make([]byte, 20) + binary.BigEndian.PutUint32(data, 20) + copy(data[4:], "ftyp") + copy(data[8:], tt.major) + copy(data[16:], tt.compatible) + got := lineImageMediaInfo(data) + if got.mimeType != tt.want || got.usedMimeFallback || got.decodeErr != nil { + t.Fatalf("unexpected metadata: %+v", got) + } + for _, size := range []uint32{0, 12, 1024, 0xffffffff} { + binary.BigEndian.PutUint32(data, size) + if got := lineImageMediaInfo(data); !got.usedMimeFallback { + t.Fatalf("accepted invalid ftyp size %d", size) + } + } + }) + } +} + +func TestLineImageExtensions(t *testing.T) { + for mimeType, want := range map[string]string{ + "image/jpeg": "jpg", "image/png; charset=binary": "png", "image/gif": "gif", + "image/webp": "webp", "image/heic": "heic", "image/heif": "heif", "image/avif": "avif", + "image/bmp": "bmp", "image/tiff": "tiff", "image/svg+xml": "svg", "image/x-icon": "ico", "unknown": "jpg", + } { + if got := imageExtensionForMIME(mimeType); got != want { + t.Errorf("%s: got %s, want %s", mimeType, got, want) + } + } +} From 298dfad1410d3abe27cc07f9240ea35ea7f86be8 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 16:00:36 -0400 Subject: [PATCH 03/35] test: make signing and cancellation checks race-stable Replace 6000 expensive eviction cache misses with three complete cache rotations, per-call victim and LRU assertions, and explicit refresh-boundary coverage. Preserve the separate 6000-call reuse and real heap-exhaustion tests. Give cold signing a separate setup budget while retaining the one-second cancellation bound; cancel and join the request worker on every exit path. --- pkg/line/methods_context_test.go | 17 +++++++++++++- pkg/runner_secret_test.go | 40 +++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pkg/line/methods_context_test.go b/pkg/line/methods_context_test.go index b6b0bcf1..ff591417 100644 --- a/pkg/line/methods_context_test.go +++ b/pkg/line/methods_context_test.go @@ -21,14 +21,29 @@ func testRPCContextCancellation(t *testing.T, method string, call func(*Client, } ctx, cancel := context.WithCancel(context.Background()) + defer cancel() result := make(chan error, 1) + done := make(chan struct{}) go func() { + defer close(done) result <- call(client, ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Errorf("%s worker did not exit after cleanup cancellation", method) + } + }) select { case <-requestStarted: - case <-time.After(time.Second): + case err := <-result: + t.Fatalf("%s failed before reaching transport: %v", method, err) + // Cold white-box signing happens before RoundTrip and is substantially + // slower under -race. This is a setup bound, not the cancellation bound. + case <-time.After(30 * time.Second): t.Fatalf("%s request did not start", method) } cancel() diff --git a/pkg/runner_secret_test.go b/pkg/runner_secret_test.go index 3ce7a194..45127d0f 100644 --- a/pkg/runner_secret_test.go +++ b/pkg/runner_secret_test.go @@ -2,6 +2,7 @@ package line import ( "bytes" + "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -185,8 +186,16 @@ func TestGetSignatureEvictsSigningContexts(t *testing.T) { } r := newSigningTestRunner(t) wantByToken := make(map[string]string) - for i := 0; i < 6000; i++ { + t.Cleanup(r.Close) + // Three complete rotations exercise initial fill, eviction and recreation + // of every token. Thousands of white-box key derivations test throughput, + // not additional cache states, and exceed the race suite's time budget. + for i := 0; i < 3*(signingCacheCapacity+2); i++ { token := fmt.Sprintf("token-%d", i%(signingCacheCapacity+2)) + var victim [sha256.Size]byte + if len(r.signingLRU) == signingCacheCapacity { + victim = r.signingLRU[0] + } signature, err := r.GetSignature("/api/test", "{}", token) if err != nil { t.Fatalf("signature %d failed: %v", i+1, err) @@ -196,10 +205,39 @@ func TestGetSignatureEvictsSigningContexts(t *testing.T) { } else if signature != want { t.Fatalf("signature for %q changed: got %q want %q", token, signature, want) } + if got := len(r.signingContexts); got != min(i+1, signingCacheCapacity) { + t.Fatalf("signature %d: cache size %d", i+1, got) + } + if len(r.signingLRU) != len(r.signingContexts) { + t.Fatal("LRU and context map sizes differ") + } + if _, retained := r.signingContexts[victim]; retained { + t.Fatal("least recently used signing context was not evicted") + } + key := sha256.Sum256([]byte(token)) + if r.signingLRU[len(r.signingLRU)-1] != key { + t.Fatal("new signing context is not most recently used") + } } if got := len(r.signingContexts); got != signingCacheCapacity { t.Fatalf("unexpected signing cache size: got %d want %d", got, signingCacheCapacity) } + // Exercise the refresh boundary with a populated, churned cache without + // deriving thousands of keys just to advance the successful-call counter. + oldRuntime := r.signingRT + r.signingCalls = signingRefreshCalls - 1 + for i := 0; i < 2; i++ { + got, err := r.GetSignature("/api/test", "{}", "token-5") + if err != nil || got != wantByToken["token-5"] { + t.Fatalf("signature across refresh = %q, %v", got, err) + } + if (r.signingRT != oldRuntime) != (i == 1) { + t.Fatal("signing runtime refreshed at the wrong call boundary") + } + } + if len(r.signingContexts) != 1 || len(r.signingLRU) != 1 { + t.Fatal("refresh retained stale signing contexts") + } } func TestGetSignatureRecoversFromExhaustedRuntime(t *testing.T) { From 695d6d90ef574d8b58faff4db1cb380abbcfa9ea Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 17:03:48 -0400 Subject: [PATCH 04/35] fix: resolve public LINE IDs before creating DM portals Share exact identifier lookup with search, validate canonical user MIDs and reuse returned contact data. Delay portal persistence until remote resolution succeeds, honor lookup-only requests, and pass complete DM information to bridgev2. Add cancellable lookup RPCs and isolated database regressions for identity consistency, errors, cancellation, concurrent portal reuse and Matrix creation retries. --- pkg/connector/resolve_identifier.go | 92 ++++++ pkg/connector/resolve_identifier_test.go | 346 +++++++++++++++++++++++ pkg/connector/userinfo.go | 66 +++-- pkg/line/methods.go | 24 +- pkg/line/methods_context_test.go | 20 ++ 5 files changed, 519 insertions(+), 29 deletions(-) create mode 100644 pkg/connector/resolve_identifier.go create mode 100644 pkg/connector/resolve_identifier_test.go diff --git a/pkg/connector/resolve_identifier.go b/pkg/connector/resolve_identifier.go new file mode 100644 index 00000000..76772dab --- /dev/null +++ b/pkg/connector/resolve_identifier.go @@ -0,0 +1,92 @@ +package connector + +import ( + "context" + "fmt" + "strings" + "time" + "unicode" + + "github.com/beeper/line/pkg/line" +) + +func isCanonicalUserMID(identifier string) bool { + if len(identifier) != 33 || identifier[0] != 'u' { + return false + } + for _, ch := range identifier[1:] { + if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f') { + return false + } + } + return true +} + +// resolveContactIdentifier never matches display names or creates portals. +// Public IDs are resolved afresh: caching an ID->MID mapping could route a +// reassigned public ID to the wrong recipient. Contact data is cached by MID. +func (lc *LineClient) resolveContactIdentifier(ctx context.Context, identifier string) (*line.Contact, error) { + if err := lc.checkLineCall(ctx); err != nil { + return nil, err + } + identifier = strings.ToLower(strings.TrimSpace(identifier)) + if identifier == "" || strings.ContainsFunc(identifier, unicode.IsSpace) || strings.ContainsFunc(identifier, unicode.IsControl) { + return nil, fmt.Errorf("provide a LINE public ID or user MID, not a display name") + } + var contact *line.Contact + var err error + if isCanonicalUserMID(identifier) { + lc.cacheMu.Lock() + cached, ok := lc.contactCache[identifier] + lc.cacheMu.Unlock() + if ok && cached.Contact.Mid == identifier && time.Since(cached.cachedAt) < contactCacheTTL { + return &cached.Contact, nil + } + if lc.isOwnMID(identifier) { + _, profile, lookupErr := callLineResult(lc, ctx, func(client *line.Client) (*line.Profile, error) { + return client.GetProfileContext(ctx) + }) + err = lookupErr + if profile != nil { + contact = &line.Contact{Mid: profile.Mid, DisplayName: profile.DisplayName, PicturePath: profile.PicturePath} + } + } else { + _, contacts, lookupErr := callLineResult(lc, ctx, func(client *line.Client) (*line.ContactsResponse, error) { + return client.GetContactsV2Context(ctx, []string{identifier}) + }) + err = lookupErr + if contacts != nil { + if wrapper, found := contacts.Contacts[identifier]; found { + contact = &wrapper.Contact + } + } + if err == nil && contact == nil { + _, buddy, buddyErr := callLineResult(lc, ctx, func(client *line.Client) (*line.BuddyProfile, error) { + return client.GetBuddyProfileContext(ctx, identifier) + }) + err = buddyErr + if buddy != nil && buddy.Mid != "" { + contact = &line.Contact{Mid: buddy.Mid, DisplayName: buddy.DisplayName, PicturePath: buddy.PicturePath} + } + } + } + } else { + _, contact, err = callLineResult(lc, ctx, func(client *line.Client) (*line.Contact, error) { + return client.FindContactByUseridContext(ctx, identifier) + }) + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + return nil, fmt.Errorf("LINE identifier lookup failed (the account may restrict ID lookup): %w", err) + } + if contact == nil || contact.Mid == "" { + return nil, nil + } + if !isCanonicalUserMID(contact.Mid) || isCanonicalUserMID(identifier) && contact.Mid != identifier { + return nil, fmt.Errorf("LINE lookup returned an invalid or mismatched user MID") + } + lc.setCachedContact(contact.Mid, *contact) + return contact, nil +} diff --git a/pkg/connector/resolve_identifier_test.go b/pkg/connector/resolve_identifier_test.go new file mode 100644 index 00000000..b653cf05 --- /dev/null +++ b/pkg/connector/resolve_identifier_test.go @@ -0,0 +1,346 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/line" +) + +const resolutionMID = "u0123456789abcdef0123456789abcdef" +const resolutionOwnMID = "ufedcba9876543210fedcba9876543210" + +type resolutionTransport func(*http.Request) (*http.Response, error) + +func (f resolutionTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type resolutionMatrix struct { + bridgev2.MatrixConnector + mu sync.Mutex + created int + failCreate bool +} + +func (*resolutionMatrix) Init(*bridgev2.Bridge) {} +func (m *resolutionMatrix) BotIntent() bridgev2.MatrixAPI { + return &resolutionIntent{mxid: "@bot:example.com", matrix: m} +} +func (m *resolutionMatrix) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI { + return &resolutionIntent{mxid: id.UserID("@line_" + userID + ":example.com"), matrix: m} +} +func (*resolutionMatrix) GetCapabilities() *bridgev2.MatrixCapabilities { + return &bridgev2.MatrixCapabilities{AutoJoinInvites: true} +} +func (*resolutionMatrix) ParseGhostMXID(id.UserID) (networkid.UserID, bool) { return "", false } +func (*resolutionMatrix) GenerateDeterministicRoomID(networkid.PortalKey) id.RoomID { return "" } +func (*resolutionMatrix) GetMembers(context.Context, id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + return map[id.UserID]*event.MemberEventContent{"@owner:example.com": {Membership: event.MembershipJoin}}, nil +} + +type resolutionIntent struct { + bridgev2.MatrixAPI + mxid id.UserID + matrix *resolutionMatrix +} + +func (i *resolutionIntent) GetMXID() id.UserID { return i.mxid } +func (*resolutionIntent) IsDoublePuppet() bool { return false } +func (*resolutionIntent) SetDisplayName(context.Context, string) error { return nil } +func (*resolutionIntent) SetAvatarURL(context.Context, id.ContentURIString) error { return nil } +func (*resolutionIntent) SetExtraProfileMeta(context.Context, any) error { return nil } +func (*resolutionIntent) EnsureInvited(context.Context, id.RoomID, id.UserID) error { return nil } +func (*resolutionIntent) EnsureJoined(context.Context, id.RoomID, ...bridgev2.EnsureJoinedParams) error { + return nil +} +func (*resolutionIntent) SendState(context.Context, id.RoomID, event.Type, string, *event.Content, time.Time) (*mautrix.RespSendEvent, error) { + return &mautrix.RespSendEvent{EventID: "$state"}, nil +} +func (i *resolutionIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) { + i.matrix.mu.Lock() + defer i.matrix.mu.Unlock() + if i.matrix.failCreate { + return "", errors.New("injected Matrix create failure") + } + i.matrix.created++ + return id.RoomID(fmt.Sprintf("!created%d:example.com", i.matrix.created)), nil +} + +func newResolutionClient(t *testing.T, transport resolutionTransport) *LineClient { + t.Helper() + db, err := dbutil.NewWithDialect("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "bridge.db")), "sqlite3") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + connector := &LineConnector{} + br := bridgev2.NewBridge("test", db, zerolog.New(io.Discard), &bridgeconfig.BridgeConfig{SplitPortals: true}, &resolutionMatrix{}, connector, func(*bridgev2.Bridge) bridgev2.CommandProcessor { return nil }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + br.BackgroundCtx = ctx + if err = br.DB.Upgrade(ctx); err != nil { + t.Fatal(err) + } + user := &database.User{MXID: "@owner:example.com"} + if err = br.DB.User.Insert(ctx, user); err != nil { + t.Fatal(err) + } + stored := &database.UserLogin{ID: resolutionOwnMID, UserMXID: user.MXID, Metadata: &UserLoginMetadata{AccessToken: "test-token", Mid: resolutionOwnMID}} + if err = br.DB.UserLogin.Insert(ctx, stored); err != nil { + t.Fatal(err) + } + login, err := br.GetExistingUserLoginByID(ctx, stored.ID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(login.BridgeState.Destroy) + lc := login.Client.(*LineClient) + lc.setCachedContact(resolutionOwnMID, line.Contact{Mid: resolutionOwnMID, DisplayName: "Owner"}) + original := newLineAPIClient + newLineAPIClient = func(token string) *line.Client { + client := line.NewClient(token) + client.HTTPClient.Transport = transport + return client + } + t.Cleanup(func() { newLineAPIClient = original }) + return lc +} + +func resolutionResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func assertPortalCount(t *testing.T, lc *LineClient, want int) { + t.Helper() + portals, err := lc.UserLogin.Bridge.DB.Portal.GetAll(context.Background()) + if err != nil || len(portals) != want { + t.Fatalf("portal count = %d, err=%v, want %d", len(portals), err, want) + } +} + +func TestResolveIdentifierPublicMIDAndSearch(t *testing.T) { + var calls []string + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + calls = append(calls, req.URL.Path) + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + t.Fatalf("unexpected RPC (possible bulk sync or duplicate lookup): %s", req.URL.Path) + } + var args []string + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 1 || args[0] != "carol.id" { + t.Fatalf("public ID arguments = %v, %v", args, err) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionMID + `","displayName":"Carol"}}`), nil + }) + ctx := context.Background() + searched, err := lc.SearchUsers(ctx, " CAROL.ID ") + if err != nil || len(searched) != 1 || searched[0].UserID != resolutionMID { + t.Fatalf("search = %+v, %v", searched, err) + } + assertPortalCount(t, lc, 0) + resolved, err := lc.ResolveIdentifier(ctx, "carol.id", true) + if err != nil { + t.Fatal(err) + } + if resolved.UserID != searched[0].UserID || resolved.Chat.PortalKey.ID != resolutionMID || resolved.Chat.PortalKey.Receiver != lc.UserLogin.ID { + t.Fatalf("incorrect canonical portal: %+v", resolved) + } + if resolved.Chat.Portal != nil || resolved.Chat.PortalInfo.Members == nil || *resolved.Chat.PortalInfo.Type != database.RoomTypeDM || *resolved.Chat.PortalInfo.Name != "Carol" { + t.Fatalf("missing prevalidated DM info: %+v", resolved.Chat) + } + assertPortalCount(t, lc, 0) + midResult, err := lc.ResolveIdentifier(ctx, resolutionMID, true) + if err != nil || midResult.Chat.PortalKey != resolved.Chat.PortalKey { + t.Fatalf("MID disagrees with public ID: %+v, %v", midResult, err) + } + lookupOnly, err := lc.ResolveIdentifier(ctx, resolutionMID, false) + if err != nil || lookupOnly.Chat != nil { + t.Fatalf("lookup-only created chat: %+v, %v", lookupOnly, err) + } + if len(calls) != 2 { + t.Fatalf("got %d RPCs, want one per public lookup and no redundant contact fetch", len(calls)) + } +} + +func TestResolveIdentifierMIDLookup(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/getContactsV2") { + t.Fatalf("MID sent to wrong endpoint: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + resolutionMID + `":{"contact":{"mid":"` + resolutionMID + `","displayName":"MID contact"}}}}}`), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), strings.ToUpper(resolutionMID), true) + if err != nil || result == nil || result.Chat.PortalKey.ID != resolutionMID { + t.Fatalf("MID lookup = %+v, %v", result, err) + } +} + +func TestResolveIdentifierFailuresDoNotPersistPortals(t *testing.T) { + for _, tc := range []struct { + name, query, response string + wantErr bool + }{ + {"unknown", "unknown.id", `{"code":0,"data":{}}`, false}, + {"restricted", "restricted.id", `{"code":1,"message":"ID search restricted"}`, true}, + {"invalid response", "carol.id", `{"code":0,"data":{"mid":"carol.id"}}`, true}, + {"group response", "carol.id", `{"code":0,"data":{"mid":"c0123456789abcdef0123456789abcdef"}}`, true}, + {"display name", "Carol Smith", "", true}, + {"empty", " ", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if tc.response == "" { + t.Fatal("invalid identifier made a network request") + } + return resolutionResponse(tc.response), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), tc.query, true) + if result != nil || (err != nil) != tc.wantErr { + t.Fatalf("result=%+v, err=%v", result, err) + } + assertPortalCount(t, lc, 0) + }) + } +} + +func TestResolveIdentifierCancellation(t *testing.T) { + started := make(chan struct{}) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + close(started) + <-req.Context().Done() + return nil, req.Context().Err() + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := lc.ResolveIdentifier(ctx, "carol.id", true); done <- err }() + select { + case <-started: + case <-time.After(30 * time.Second): + t.Fatal("lookup did not start") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("lookup ignored cancellation") + } + assertPortalCount(t, lc, 0) +} + +func TestResolveIdentifierConcurrentCanonicalPortalReuse(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + return nil, fmt.Errorf("unexpected RPC: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionMID + `","displayName":"Carol"}}`), nil + }) + ctx := context.Background() + const count = 12 + portals := make(chan *bridgev2.Portal, count) + errors := make(chan error, count) + var wg sync.WaitGroup + for range count { + wg.Go(func() { + resolved, err := lc.ResolveIdentifier(ctx, "carol.id", true) + if err != nil { + errors <- err + return + } + portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, resolved.Chat.PortalKey) + if err != nil { + errors <- err + return + } + portals <- portal + }) + } + wg.Wait() + close(errors) + for err := range errors { + t.Error(err) + } + close(portals) + var first *bridgev2.Portal + for portal := range portals { + if first == nil { + first = portal + } else if first != portal { + t.Fatal("duplicate cached portal") + } + } + assertPortalCount(t, lc, 1) + if first == nil || first.ID != resolutionMID { + t.Fatal("missing canonical portal") + } + // An existing Matrix mapping must survive subsequent resolution. + first.MXID = "!existing:example.com" + if err := first.Save(ctx); err != nil { + t.Fatal(err) + } + resolved, err := lc.ResolveIdentifier(ctx, resolutionMID, true) + if err != nil { + t.Fatal(err) + } + existing, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, resolved.Chat.PortalKey) + if err != nil || existing.MXID != first.MXID { + t.Fatalf("existing portal not reused: %v", err) + } +} + +func TestStartChatCreatesReusesAndRetriesRoom(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + return nil, fmt.Errorf("unexpected RPC: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionMID + `","displayName":"Carol"}}`), nil + }) + matrix := lc.UserLogin.Bridge.Matrix.(*resolutionMatrix) + ctx := context.Background() + matrix.failCreate = true + if _, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "carol.id", true); err == nil { + t.Fatal("injected room creation failure was ignored") + } + // bridgev2 intentionally retains a canonical, retryable portal row after + // Matrix creation fails; no public-ID alias or wrong-recipient row exists. + assertPortalCount(t, lc, 1) + key := networkid.PortalKey{ID: resolutionMID, Receiver: lc.UserLogin.ID} + row, err := lc.UserLogin.Bridge.DB.Portal.GetByKey(ctx, key) + if err != nil || row == nil || row.MXID != "" { + t.Fatalf("failed room state: %+v %v", row, err) + } + matrix.failCreate = false + created, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "carol.id", true) + if err != nil || created == nil || created.DMRoomID == "" { + t.Fatalf("create: %+v %v", created, err) + } + existing, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, resolutionMID, true) + if err != nil || existing.DMRoomID != created.DMRoomID || existing.JustCreated { + t.Fatalf("reuse: %+v %v", existing, err) + } + if matrix.created != 1 { + t.Fatalf("created %d rooms", matrix.created) + } + assertPortalCount(t, lc, 1) +} diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 15d8fdbf..7d67cde5 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -203,6 +203,10 @@ func (lc *LineClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) } contact := lc.getContact(ctx, string(portal.ID)) + return lc.directChatInfo(mid, contact), nil +} + +func (lc *LineClient) directChatInfo(mid string, contact line.Contact) *bridgev2.ChatInfo { dmType := database.RoomTypeDM chatName := contact.EffectiveDisplayName() return &bridgev2.ChatInfo{ @@ -223,14 +227,14 @@ func (lc *LineClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) }, { EventSender: bridgev2.EventSender{ - Sender: networkid.UserID(portal.ID), + Sender: networkid.UserID(mid), }, Membership: event.MembershipJoin, PowerLevel: ptr.Ptr(0), }, }), }, - }, nil + } } func (lc *LineClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { @@ -304,24 +308,30 @@ func (lc *LineClient) setCachedContact(mid string, contact line.Contact) { } func (lc *LineClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { - userID := makeUserID(strings.TrimSpace(identifier)) - portalID := networkid.PortalKey{ID: makePortalID(string(userID)), Receiver: lc.UserLogin.ID} + contact, err := lc.resolveContactIdentifier(ctx, identifier) + if err != nil || contact == nil { + return nil, err + } + userID := makeUserID(contact.Mid) ghost, err := lc.UserLogin.Bridge.GetGhostByID(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get ghost: %w", err) } - portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, portalID) - if err != nil { - return nil, fmt.Errorf("failed to get portal: %w", err) - } - ghostInfo, _ := lc.GetUserInfo(ctx, ghost) - portalInfo, _ := lc.GetChatInfo(ctx, portal) - return &bridgev2.ResolveIdentifierResponse{ + name := contact.EffectiveDisplayName() + resp := &bridgev2.ResolveIdentifierResponse{ Ghost: ghost, UserID: userID, - UserInfo: ghostInfo, - Chat: &bridgev2.CreateChatResponse{Portal: portal, PortalKey: portalID, PortalInfo: portalInfo}, - }, nil + UserInfo: &bridgev2.UserInfo{Identifiers: []string{contact.Mid}, Name: &name, Avatar: lc.avatarFromPicturePath(contact.PicturePath)}, + } + if createChat { + // Resolve and validate before bridgev2 persists anything. Its canonical + // portal cache and room-creation event loop handle reuse and concurrency. + resp.Chat = &bridgev2.CreateChatResponse{ + PortalKey: networkid.PortalKey{ID: makePortalID(contact.Mid), Receiver: lc.UserLogin.ID}, + PortalInfo: lc.directChatInfo(contact.Mid, *contact), + } + } + return resp, nil } func (lc *LineClient) midToResolveIdentifier(ctx context.Context, mid string) *bridgev2.ResolveIdentifierResponse { @@ -344,27 +354,29 @@ func (lc *LineClient) midToResolveIdentifier(ctx context.Context, mid string) *b func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { var results []*bridgev2.ResolveIdentifierResponse - // Try by LINE user ID first + // Exact public ID and MID lookup is shared with start-chat. Only search + // falls back to non-unique display-name matches. lowerQuery := strings.ToLower(strings.TrimSpace(query)) if lowerQuery != "" { - _, contact, err := callLineResult(lc, ctx, func(client *line.Client) (*line.Contact, error) { - return client.FindContactByUserid(lowerQuery) - }) - if err == nil && contact != nil && contact.Mid != "" { + contact, err := lc.resolveContactIdentifier(ctx, query) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err == nil && contact != nil { if r := lc.midToResolveIdentifier(ctx, contact.Mid); r != nil { - results = append(results, r) - if len(results) >= 20 { - return results, nil - } + return []*bridgev2.ResolveIdentifierResponse{r}, nil } } } // Search contacts by display name client, allMids, err := callLineResult(lc, ctx, func(client *line.Client) ([]string, error) { - return client.GetAllContactIds() + return client.GetAllContactIdsContext(ctx) }) if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to get all contact IDs for search") return results, nil } @@ -376,8 +388,11 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev end = len(allMids) } batch := allMids[i:end] - contactsResp, err := client.GetContactsV2(batch) + contactsResp, err := client.GetContactsV2Context(ctx, batch) if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } continue } for _, wrapper := range contactsResp.Contacts { @@ -387,6 +402,7 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev } name := strings.ToLower(c.EffectiveDisplayName()) if lowerQuery == "" || strings.Contains(name, lowerQuery) { + lc.setCachedContact(c.Mid, c) if r := lc.midToResolveIdentifier(ctx, c.Mid); r != nil { results = append(results, r) } diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 08a0ca04..ac67286f 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -487,8 +487,12 @@ func (c *Client) SendChatChecked(chatMid, messageID string) error { // GetContactsV2 fetches contact details for a list of MIDs. func (c *Client) GetContactsV2(mids []string) (*ContactsResponse, error) { + return c.GetContactsV2Context(c.requestContext(), mids) +} + +func (c *Client) GetContactsV2Context(ctx context.Context, mids []string) (*ContactsResponse, error) { req := GetContactsV2Request{TargetUserMids: mids} - resp, err := c.callRPC("TalkService", "getContactsV2", req, 2) + resp, err := c.callRPCContext(ctx, "TalkService", "getContactsV2", req, 2) if err != nil { return nil, err } @@ -508,7 +512,11 @@ func (c *Client) GetContactsV2(mids []string) (*ContactsResponse, error) { // GetBuddyProfile fetches the profile of a LINE official/business account (buddy). func (c *Client) GetBuddyProfile(mid string) (*BuddyProfile, error) { - resp, err := c.callRPC("BuddyService", "getBuddyProfile", mid) + return c.GetBuddyProfileContext(c.requestContext(), mid) +} + +func (c *Client) GetBuddyProfileContext(ctx context.Context, mid string) (*BuddyProfile, error) { + resp, err := c.callRPCContext(ctx, "BuddyService", "getBuddyProfile", mid) if err != nil { return nil, err } @@ -1039,7 +1047,11 @@ func (c *Client) RejectChatInvitationContext(ctx context.Context, reqSeq int64, // FindContactByUserid looks up a LINE user by their user ID (not MID). func (c *Client) FindContactByUserid(userid string) (*Contact, error) { - resp, err := c.callRPC("TalkService", "findContactByUserid", userid) + return c.FindContactByUseridContext(c.requestContext(), userid) +} + +func (c *Client) FindContactByUseridContext(ctx context.Context, userid string) (*Contact, error) { + resp, err := c.callRPCContext(ctx, "TalkService", "findContactByUserid", userid) if err != nil { return nil, err } @@ -1059,7 +1071,11 @@ func (c *Client) FindContactByUserid(userid string) (*Contact, error) { // GetAllContactIds returns the MIDs of all contacts. func (c *Client) GetAllContactIds() ([]string, error) { - resp, err := c.callRPC("TalkService", "getAllContactIds") + return c.GetAllContactIdsContext(c.requestContext()) +} + +func (c *Client) GetAllContactIdsContext(ctx context.Context) ([]string, error) { + resp, err := c.callRPCContext(ctx, "TalkService", "getAllContactIds") if err != nil { return nil, err } diff --git a/pkg/line/methods_context_test.go b/pkg/line/methods_context_test.go index ff591417..65f10dff 100644 --- a/pkg/line/methods_context_test.go +++ b/pkg/line/methods_context_test.go @@ -71,3 +71,23 @@ func TestGetProfileContextCancellation(t *testing.T) { return err }) } + +func TestIdentifierLookupContextCancellation(t *testing.T) { + for name, call := range map[string]func(*Client, context.Context) error{ + "findContactByUserid": func(c *Client, ctx context.Context) error { + _, err := c.FindContactByUseridContext(ctx, "carol") + return err + }, + "getContactsV2": func(c *Client, ctx context.Context) error { + _, err := c.GetContactsV2Context(ctx, []string{"u0123456789abcdef0123456789abcdef"}) + return err + }, + "getBuddyProfile": func(c *Client, ctx context.Context) error { + _, err := c.GetBuddyProfileContext(ctx, "u0123456789abcdef0123456789abcdef") + return err + }, + "getAllContactIds": func(c *Client, ctx context.Context) error { _, err := c.GetAllContactIdsContext(ctx); return err }, + } { + t.Run(name, func(t *testing.T) { testRPCContextCancellation(t, name, call) }) + } +} From 09a86852c1c269552e1b228650a37eec66adcc4f Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 17:08:01 -0400 Subject: [PATCH 05/35] fix: serialize QR login operations and preserve deadline errors Reject overlapping Start/Wait calls before they consume polling stages or mutate authentication state. Preserve caller and attempt expiry instead of reporting every canceled start as context.Canceled. Reproduce both defects and cover mocked QR presentation, scan/PIN approval, token verification, missing-keychain persistence rejection, expiry and cancellation. --- pkg/connector/login_qr.go | 48 ++++++-- pkg/connector/login_qr_test.go | 193 +++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 pkg/connector/login_qr_test.go diff --git a/pkg/connector/login_qr.go b/pkg/connector/login_qr.go index eb1b1471..af7d0142 100644 --- a/pkg/connector/login_qr.go +++ b/pkg/connector/login_qr.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "sync" "time" "maunium.net/go/mautrix/bridgev2" @@ -11,18 +12,23 @@ import ( ) type LineQRLogin struct { - login *LineEmailLogin - client *line.Client - session string - qr *line.QRCodeResponse - poll chan error - pin bool + operationMu sync.Mutex + login *LineEmailLogin + client *line.Client + session string + qr *line.QRCodeResponse + poll chan error + pin bool } var _ bridgev2.LoginProcessDisplayAndWait = (*LineQRLogin)(nil) var _ bridgev2.LoginProcessWithOverride = (*LineQRLogin)(nil) func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { + if !lq.operationMu.TryLock() { + return nil, fmt.Errorf("QR login operation already in progress") + } + defer lq.operationMu.Unlock() meta, ok := override.Metadata.(*UserLoginMetadata) if !ok { return nil, fmt.Errorf("existing LINE login metadata has unexpected type %T", override.Metadata) @@ -32,10 +38,21 @@ func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2 if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 { lq.login.Certificate = "" } - return lq.Start(ctx) + return lq.start(ctx) } func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + if !lq.operationMu.TryLock() { + return nil, fmt.Errorf("QR login operation already in progress") + } + defer lq.operationMu.Unlock() + return lq.start(ctx) +} + +func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := ctx.Err(); err != nil { + return nil, err + } ll := lq.login ll.mu.Lock() if ll.canceled || ll.attemptCtx != nil { @@ -55,10 +72,15 @@ func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { canceled := ll.canceled || ctx.Err() != nil || processCtx.Err() != nil ll.mu.Unlock() if err != nil || canceled { - lq.Cancel() - if canceled { - return nil, context.Canceled + // Preserve expiry before Cancel changes the attempt's context error. + if ctx.Err() != nil { + err = ctx.Err() + } else if processCtx.Err() != nil { + err = processCtx.Err() + } else if canceled { + err = context.Canceled } + lq.Cancel() return nil, err } lq.client, lq.session, lq.qr = client, session, qr @@ -101,6 +123,12 @@ func (lq *LineQRLogin) startPoll(check func(context.Context, string) error, retr } func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { + // Overlapping provisioning calls must not consume each other's polling + // results or advance the PIN/token exchange twice. Cancel doesn't take this lock. + if !lq.operationMu.TryLock() { + return nil, fmt.Errorf("QR login operation already in progress") + } + defer lq.operationMu.Unlock() if lq.poll == nil { return nil, fmt.Errorf("QR login has not started") } diff --git a/pkg/connector/login_qr_test.go b/pkg/connector/login_qr_test.go new file mode 100644 index 00000000..f35be5b5 --- /dev/null +++ b/pkg/connector/login_qr_test.go @@ -0,0 +1,193 @@ +package connector + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "maunium.net/go/mautrix/bridgev2" +) + +func TestQRConcurrentWaitIsRejected(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + lq := &LineQRLogin{login: &LineEmailLogin{attemptCtx: ctx, attemptCancel: cancel}, poll: make(chan error, 1)} + results := make(chan error, 2) + for range 2 { + go func() { _, err := lq.Wait(context.Background()); results <- err }() + } + select { + case err := <-results: + if err == nil || !strings.Contains(err.Error(), "already in progress") { + t.Errorf("unexpected concurrent Wait result: %v", err) + } + case <-time.After(time.Second): + t.Error("concurrent Wait calls both blocked instead of rejecting the duplicate") + cancel() + <-results + } + cancel() + select { + case err := <-results: + if !errors.Is(err, context.Canceled) { + t.Errorf("active Wait ignored cancellation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("active Wait did not stop") + } +} + +func TestQRApprovalPINAndTokenValidation(t *testing.T) { + approved, pinApproved := make(chan struct{}), make(chan struct{}) + var mu sync.Mutex + var calls []string + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + mu.Lock() + calls = append(calls, method) + mu.Unlock() + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified", "checkPinCodeVerified": + if req.Header.Get("X-Line-Session-ID") != "qr-session" || req.Header.Get("x-hmac") == "" { + return nil, errors.New("missing QR authentication headers") + } + gate := approved + if method == "checkPinCodeVerified" { + gate = pinApproved + } + select { + case <-gate: + case <-req.Context().Done(): + return nil, req.Context().Err() + } + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "qrCodeLoginV2": + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionOwnMID + `","tokenV3IssueResult":{"accessToken":"qr-token","refreshToken":"qr-refresh"},"metaData":{}}}`), nil + case "getProfile": + if req.Header.Get("x-line-access") != "qr-token" { + return nil, errors.New("QR token was not used for verification") + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionOwnMID + `","displayName":"Owner"}}`), nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + step, err := lq.Start(ctx) + if err != nil || step.DisplayAndWaitParams.Type != bridgev2.LoginDisplayTypeQR { + t.Fatalf("QR start: %+v %v", step, err) + } + callback, err := url.Parse(step.DisplayAndWaitParams.Data) + if err != nil { + t.Fatal(err) + } + key, err := base64.StdEncoding.DecodeString(callback.Query().Get("secret")) + if err != nil || len(key) != 32 || callback.Query().Get("e2eeVersion") != "1" { + t.Fatal("QR callback missing login key material") + } + close(approved) + step, err = lq.Wait(ctx) + if err != nil || step.DisplayAndWaitParams.Type != bridgev2.LoginDisplayTypeCode || step.DisplayAndWaitParams.Data != "123456" { + t.Fatalf("PIN step: %+v %v", step, err) + } + close(pinApproved) + step, err = lq.Wait(ctx) + if step != nil || !errors.Is(err, ErrLoginNoKeychain) { + t.Fatalf("missing E2EE keychain was accepted: %+v %v", step, err) + } + stored, err := lc.UserLogin.Bridge.DB.UserLogin.GetByID(ctx, lc.UserLogin.ID) + if err != nil || stored.Metadata.(*UserLoginMetadata).AccessToken != "test-token" { + t.Fatalf("incomplete QR login changed stored session: %v", err) + } + mu.Lock() + defer mu.Unlock() + want := "[createSession createQrCode checkQrCodeVerified verifyCertificate createPinCode checkPinCodeVerified qrCodeLoginV2 getProfile]" + if fmt.Sprint(calls) != want { + t.Fatalf("unexpected QR state transitions: %v", calls) + } +} + +func TestQRExpiryAndCancellation(t *testing.T) { + for _, expire := range []bool{false, true} { + t.Run(fmt.Sprint(expire), func(t *testing.T) { + pollStarted, pollStopped := make(chan struct{}), make(chan struct{}) + newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/createSession"): + return resolutionResponse(`{"code":0,"data":{"authSessionId":"session"}}`), nil + case strings.HasSuffix(req.URL.Path, "/createQrCode"): + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case strings.HasSuffix(req.URL.Path, "/checkQrCodeVerified"): + close(pollStarted) + defer close(pollStopped) + if expire { + return resolutionResponse(`{"code":10052,"data":{"statusCode":410}}`), nil + } + <-req.Context().Done() + return nil, req.Context().Err() + default: + return nil, fmt.Errorf("approval was bypassed: %s", req.URL.Path) + } + }) + lq := &LineQRLogin{login: &LineEmailLogin{}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + select { + case <-pollStarted: + case <-ctx.Done(): + t.Fatal("poll did not start") + } + if !expire { + lq.Cancel() + } + step, err := lq.Wait(ctx) + want := error(context.Canceled) + if expire { + want = ErrLoginVerificationFailed + } + if step != nil || !errors.Is(err, want) { + t.Fatalf("unexpected terminal state: %+v %v", step, err) + } + select { + case <-pollStopped: + case <-ctx.Done(): + t.Fatal("poll worker leaked") + } + }) + } +} + +func TestQRStartPreservesDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + lq := &LineQRLogin{login: &LineEmailLogin{}} + newResolutionClient(t, func(*http.Request) (*http.Response, error) { return nil, ctx.Err() }) + step, err := lq.Start(ctx) + if step != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("start after deadline = %+v, %v; want DeadlineExceeded", step, err) + } +} From 6e333e540f886b67cf0f9487252ec3eb527bc5f7 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Wed, 21 Oct 2026 17:43:56 -0400 Subject: [PATCH 06/35] diagnostics: preserve sanitized QR login rejection details --- pkg/line/qr.go | 53 +++++++++++++++++++++++++++++------ pkg/line/qr_error_test.go | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 pkg/line/qr_error_test.go diff --git a/pkg/line/qr.go b/pkg/line/qr.go index 75b63134..73a8919f 100644 --- a/pkg/line/qr.go +++ b/pkg/line/qr.go @@ -95,17 +95,38 @@ func (c *Client) VerifyQRCertificate(ctx context.Context, session, certificate s } type qrRPCError struct { - method string - httpStatus, code, statusCode int + method string + httpStatus int + code *int + statusCode *int + exceptionCode *int + exceptionType string + reason string } func (e *qrRPCError) Error() string { - return fmt.Sprintf("QR %s failed: HTTP %d, code %d, status %d", e.method, e.httpStatus, e.code, e.statusCode) + message := fmt.Sprintf("QR %s failed: HTTP %d", e.method, e.httpStatus) + if e.code != nil { + message += fmt.Sprintf(", code %d", *e.code) + } + if e.statusCode != nil { + message += fmt.Sprintf(", status %d", *e.statusCode) + } + if e.exceptionCode != nil { + message += fmt.Sprintf(", exception code %d", *e.exceptionCode) + } + if e.exceptionType != "" { + message += ", exception type " + e.exceptionType + } + if e.reason != "" { + message += ", reason " + e.reason + } + return message } func IsQRLoginPollExpired(err error) bool { var response *qrRPCError - return errors.As(err, &response) && response.code == 10052 && response.statusCode == 410 + return errors.As(err, &response) && response.code != nil && *response.code == 10052 && response.statusCode != nil && *response.statusCode == 410 } func (c *Client) CreatePinCode(ctx context.Context, session string) (string, error) { @@ -206,15 +227,29 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime } decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&wrapper) if resp.StatusCode != http.StatusOK || (wrapper.Code != nil && *wrapper.Code != 0) { - response := &qrRPCError{method: method, httpStatus: resp.StatusCode} - if wrapper.Code != nil { - response.code = *wrapper.Code - } + response := &qrRPCError{method: method, httpStatus: resp.StatusCode, code: wrapper.Code} var details struct { - StatusCode int `json:"statusCode"` + StatusCode *int `json:"statusCode"` + Code *int `json:"code"` + Name string `json:"name"` + Reason string `json:"reason"` } if json.Unmarshal(wrapper.Data, &details) == nil { response.statusCode = details.StatusCode + response.exceptionCode = details.Code + // Provider strings may contain credentials. Retain fixed schema labels + // and allowlisted reasons only, never arbitrary message/reason values. + switch details.Name { + case "TalkException", "LoginException", "SecondaryQrCodeLoginException": + response.exceptionType = details.Name + case "": + default: + response.exceptionType = "other" + } + response.reason = safeLoginPollingReason(details.Reason) + if details.Reason != "" && response.reason == "" { + response.reason = "redacted" + } } return response } diff --git a/pkg/line/qr_error_test.go b/pkg/line/qr_error_test.go new file mode 100644 index 00000000..706b8413 --- /dev/null +++ b/pkg/line/qr_error_test.go @@ -0,0 +1,59 @@ +package line + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + gen "github.com/beeper/line/pkg" +) + +func TestQRErrorDetailsAndRedaction(t *testing.T) { + for _, tc := range []struct { + name, body, want string + status int + expired bool + }{ + {"nested rejection", `{"code":10051,"message":"PRIVATE","data":{"name":"TalkException","code":35,"reason":"blocked user","accessToken":"PRIVATE","authSessionId":"PRIVATE"}}`, "exception code 35, exception type TalkException, reason blocked user", 400, false}, + {"HTTP success API failure", `{"code":10051,"data":{"name":"SecondaryQrCodeLoginException","code":0,"reason":"PRIVATE"}}`, "exception code 0, exception type SecondaryQrCodeLoginException, reason redacted", 200, false}, + {"unknown exception", `{"code":10051,"data":{"name":"PRIVATE","code":99,"reason":"PRIVATE"}}`, "exception code 99, exception type other, reason redacted", 400, false}, + {"poll expired", `{"code":10052,"data":{"statusCode":410}}`, "code 10052, status 410", 400, true}, + {"missing detail", `{"code":10051,"data":{}}`, "QR qrCodeLoginV2 failed: HTTP 400, code 10051", 400, false}, + {"malformed", `PRIVATE`, "QR qrCodeLoginV2 failed: HTTP 400", 400, false}, + {"invalid numeric detail", `{"code":10051,"data":{"code":"PRIVATE"}}`, "QR qrCodeLoginV2 failed: HTTP 400, code 10051", 400, false}, + } { + t.Run(tc.name, func(t *testing.T) { + runner, err := gen.NewRunner() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + client := NewClient("") + client.LoginAttempt = &LoginAttempt{Runner: runner, Context: ctx, cancel: cancel} + defer client.LoginAttempt.Close() + client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/qrCodeLoginV2") { + t.Fatal("unexpected method") + } + return &http.Response{StatusCode: tc.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.body))}, nil + }) + result, err := client.QRCodeLoginV2(ctx, "PRIVATE-SESSION") + var qrErr *qrRPCError + if result != nil || !errors.As(err, &qrErr) || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("result=%v error=%v", result, err) + } + if strings.Contains(err.Error(), "PRIVATE") || strings.Contains(err.Error(), "status 0") { + t.Fatalf("unsafe or misleading error: %v", err) + } + if IsQRLoginPollExpired(err) != tc.expired { + t.Fatal("expiry classification changed") + } + if client.AccessToken != "" { + t.Fatal("failed login installed token") + } + }) + } +} From 20e35400669499f8a63fbcf5dd3a22ee4cbde410 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Wed, 21 Oct 2026 18:07:31 -0400 Subject: [PATCH 07/35] diagnostics: audit QR identity compatibility and clarify session failures --- pkg/connector/client.go | 4 +- pkg/connector/forced_logout_test.go | 2 +- pkg/line/qr.go | 11 ++- pkg/line/qr_contract_test.go | 107 ++++++++++++++++++++++++++++ pkg/line/qr_error_test.go | 2 + readme/QR_REGRESSION_AUDIT.md | 97 +++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 pkg/line/qr_contract_test.go create mode 100644 readme/QR_REGRESSION_AUDIT.md diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4369df38..4109829e 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -21,7 +21,7 @@ import ( ) var ( - errLineSessionInvalidated = errors.New("LINE session invalidated by another client") + errLineSessionInvalidated = errors.New("LINE session invalidated") errLineClientSuperseded = errors.New("LINE client was superseded") errLineQRLoginRequired = errors.New("LINE requires a new QR scan. Reconnect in Beeper to continue") ) @@ -379,7 +379,7 @@ func (lc *LineClient) markLoggedOutByOtherClientLocked(ctx context.Context, err lc.invalidateAccessToken() lc.obsTokenCache.Invalidate() stateError := status.BridgeStateErrorCode("line-logged-out") - stateMessage := "LINE logged this Chrome Extension session out because another LINE client connected. Click Reconnect in Beeper to reconnect LINE." + stateMessage := "LINE invalidated this Chrome Extension session. Click Reconnect in Beeper to reconnect LINE." invalidSenderKey := line.IsInvalidSenderKey(err) needsFullE2EELogin := invalidSenderKey lc.missingE2EEKeyMu.Lock() diff --git a/pkg/connector/forced_logout_test.go b/pkg/connector/forced_logout_test.go index efe123a3..adf7782e 100644 --- a/pkg/connector/forced_logout_test.go +++ b/pkg/connector/forced_logout_test.go @@ -130,7 +130,7 @@ func TestInvalidSenderKeyReconnectPersistsFullVerification(t *testing.T) { select { case state := <-matrix.states: wantRestartError := status.BridgeStateErrorCode("line-logged-out") - wantRestartMessage := "LINE logged this Chrome Extension session out because another LINE client connected. Click Reconnect in Beeper to reconnect LINE." + wantRestartMessage := "LINE invalidated this Chrome Extension session. Click Reconnect in Beeper to reconnect LINE." if invalidKey { wantRestartError = "line-e2ee-key-missing" wantRestartMessage = lineMissingE2EEKeyMessage diff --git a/pkg/line/qr.go b/pkg/line/qr.go index 73a8919f..46ed301f 100644 --- a/pkg/line/qr.go +++ b/pkg/line/qr.go @@ -102,6 +102,7 @@ type qrRPCError struct { exceptionCode *int exceptionType string reason string + alert string } func (e *qrRPCError) Error() string { @@ -121,6 +122,9 @@ func (e *qrRPCError) Error() string { if e.reason != "" { message += ", reason " + e.reason } + if e.alert != "" { + message += ", alert " + e.alert + } return message } @@ -233,6 +237,7 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime Code *int `json:"code"` Name string `json:"name"` Reason string `json:"reason"` + Alert string `json:"alertMessage"` } if json.Unmarshal(wrapper.Data, &details) == nil { response.statusCode = details.StatusCode @@ -240,7 +245,7 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime // Provider strings may contain credentials. Retain fixed schema labels // and allowlisted reasons only, never arbitrary message/reason values. switch details.Name { - case "TalkException", "LoginException", "SecondaryQrCodeLoginException": + case "TalkException", "LoginException", "SecondaryQrCodeLoginException", "SecondaryQrCodeException": response.exceptionType = details.Name case "": default: @@ -250,6 +255,10 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime if details.Reason != "" && response.reason == "" { response.reason = "redacted" } + response.alert = safeLoginPollingReason(details.Alert) + if details.Alert != "" && response.alert == "" { + response.alert = "redacted" + } } return response } diff --git a/pkg/line/qr_contract_test.go b/pkg/line/qr_contract_test.go new file mode 100644 index 00000000..cbe7f4da --- /dev/null +++ b/pkg/line/qr_contract_test.go @@ -0,0 +1,107 @@ +package line + +import ( + "context" + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +// These fixtures verify the bridge's request contract, not LINE's acceptance of +// a live login. No live authentication is performed by this test. +func TestQRRequestContractAndMIDRepresentations(t *testing.T) { + for _, mid := range []string{ + "u0123456789abcdef0123456789abcdef", + "U0123456789ABCDEF0123456789ABCDEF", + "UAbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg", + } { + t.Run(mid, func(t *testing.T) { + client := NewClient("") + var calls []string + client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + calls = append(calls, method) + service := "SecondaryQrCodeLoginService" + poll := method == "checkQrCodeVerified" || method == "checkPinCodeVerified" + if poll { + service = "SecondaryQrCodeLoginPermitNoticeService" + } + if req.Method != "POST" || req.URL.Host != "line-chrome-gw.line-apps.com" || req.URL.Path != "/api/talk/thrift/LoginQrCode/"+service+"/"+method { + t.Fatal("QR endpoint contract changed") + } + if req.Header.Get("x-hmac") == "" || req.Header.Get("x-line-chrome-version") != ExtensionVersion || req.Header.Get("x-line-access") != "" || req.Header.Get("Cookie") != "" { + t.Fatal("missing signing header or existing session credentials sent to QR login") + } + if poll && req.Header.Get("X-Line-Session-ID") != "fixture-session" || !poll && req.Header.Get("X-Line-Session-ID") != "" { + t.Fatal("wrong QR session header scope") + } + var args []map[string]any + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 1 { + t.Fatal("invalid QR argument list") + } + want := map[string]any{"authSessionId": "fixture-session"} + body := `{"code":0,"data":{}}` + status := 200 + switch method { + case "createSession": + want = map[string]any{} + body = `{"code":0,"data":{"authSessionId":"fixture-session"}}` + case "createQrCode": + body = `{"code":0,"data":{"callbackUrl":"line://au/q/fixture","longPollingIntervalSec":30,"longPollingMaxCount":1}}` + case "checkQrCodeVerified": + if req.Header.Get("X-LST") != "30000" { + t.Fatal("QR poll duration changed") + } + case "verifyCertificate": + want["certificate"] = "" + status = 400 + body = `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":2}}` + case "createPinCode": + body = `{"code":0,"data":{"pinCode":"123456"}}` + case "checkPinCodeVerified": + if req.Header.Get("X-LST") != "110000" { + t.Fatal("PIN poll duration changed") + } + case "qrCodeLoginV2": + want["systemName"], want["modelName"], want["autoLoginIsRequired"] = "CHROMEOS", "CHROME", false + body = `{"code":0,"data":{"mid":"` + mid + `","tokenV3IssueResult":{"accessToken":"fixture-token"}}}` + default: + t.Fatalf("unexpected QR operation %s", method) + } + if !reflect.DeepEqual(args[0], want) { + t.Fatalf("%s args=%v want=%v", method, args[0], want) + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + }) + ctx := context.Background() + session, _, err := client.StartQRLogin(ctx) + if err != nil { + t.Fatal(err) + } + defer client.LoginAttempt.Close() + if err = client.CheckQRCodeVerifiedContext(ctx, session, 30*time.Second); err != nil { + t.Fatal(err) + } + if ok, err := client.VerifyQRCertificate(ctx, session, ""); err != nil || ok { + t.Fatalf("certificate result=%v,%v", ok, err) + } + if pin, err := client.CreatePinCode(ctx, session); err != nil || pin != "123456" { + t.Fatal("PIN issuance failed") + } + if err = client.CheckPinCodeVerifiedContext(ctx, session); err != nil { + t.Fatal(err) + } + result, err := client.QRCodeLoginV2(ctx, session) + if err != nil || result.Mid != mid || result.AuthToken != "fixture-token" || client.AccessToken != "fixture-token" { + t.Fatalf("identity/token altered: result=%+v err=%v", result, err) + } + if strings.Join(calls, ",") != "createSession,createQrCode,checkQrCodeVerified,verifyCertificate,createPinCode,checkPinCodeVerified,qrCodeLoginV2" { + t.Fatalf("unexpected sequence: %v", calls) + } + }) + } +} diff --git a/pkg/line/qr_error_test.go b/pkg/line/qr_error_test.go index 706b8413..3075f4db 100644 --- a/pkg/line/qr_error_test.go +++ b/pkg/line/qr_error_test.go @@ -20,6 +20,8 @@ func TestQRErrorDetailsAndRedaction(t *testing.T) { {"nested rejection", `{"code":10051,"message":"PRIVATE","data":{"name":"TalkException","code":35,"reason":"blocked user","accessToken":"PRIVATE","authSessionId":"PRIVATE"}}`, "exception code 35, exception type TalkException, reason blocked user", 400, false}, {"HTTP success API failure", `{"code":10051,"data":{"name":"SecondaryQrCodeLoginException","code":0,"reason":"PRIVATE"}}`, "exception code 0, exception type SecondaryQrCodeLoginException, reason redacted", 200, false}, {"unknown exception", `{"code":10051,"data":{"name":"PRIVATE","code":99,"reason":"PRIVATE"}}`, "exception code 99, exception type other, reason redacted", 400, false}, + {"QR exception with private alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"PRIVATE"}}`, "exception code 100, exception type SecondaryQrCodeException, alert redacted", 400, false}, + {"QR exception with allowlisted alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5,"alertMessage":"Too many login attempts"}}`, "exception code 5, exception type SecondaryQrCodeException, alert Too many login attempts", 400, false}, {"poll expired", `{"code":10052,"data":{"statusCode":410}}`, "code 10052, status 410", 400, true}, {"missing detail", `{"code":10051,"data":{}}`, "QR qrCodeLoginV2 failed: HTTP 400, code 10051", 400, false}, {"malformed", `PRIVATE`, "QR qrCodeLoginV2 failed: HTTP 400", 400, false}, diff --git a/readme/QR_REGRESSION_AUDIT.md b/readme/QR_REGRESSION_AUDIT.md new file mode 100644 index 00000000..0464d21b --- /dev/null +++ b/readme/QR_REGRESSION_AUDIT.md @@ -0,0 +1,97 @@ +# QR/session regression audit — October 21, 2026 + +## Scope and evidence boundary + +The operator reported that QR login worked before deployment of the public-ID +repair, and that iOS now displays an error after entering the PIN. The bridge +reports `qrCodeLoginV2`, HTTP 400, wrapper code 10051, nested exception code 100, +exception type `other`. The exact iOS error and LINE app version are still needed. + +The live lookup repair is confirmed by successful resolve/search and start-chat +lookup records. This does not establish that authentication or outbound messaging +works end to end. No standalone token probe, login attempt or remote configuration +change was performed during this audit. + +## Compared revisions + +* `7615af2`: executable installed when this troubleshooting session began; QR + login was completed by the operator before diagnostic deployment. +* `160ac20`: lookup diagnostic build, which reconnected that saved login. +* `05a1607`: opaque-ID repair; no QR, login, token recovery, SSE or send-path edits. +* `009d81c`: nested QR error reporting; changes only response error representation + and equivalent expired-poll classification, not outgoing request construction. + +`pkg/connector/login_qr.go` and `finishLogin` did not change across these builds. +The QR create-session, create-code, certificate, PIN, poll and token-issuance +request construction in `pkg/line/qr.go` also did not change. Header version, +authentication-session arguments, signing input, endpoint selection and the +`autoLoginIsRequired` value were retained. + +`TestQRRequestContractAndMIDRepresentations` runs the mocked seven-RPC QR sequence +and checks the method, URL, session continuity, argument values, header scope, +poll durations and unmodified returned account ID. It passes both on baseline +`160ac20` in an isolated worktree and on the current source. Fixtures include +lowercase and uppercase 33-byte hex IDs and a mixed-case 44-byte opaque ID. These +tests establish request-contract compatibility, not remote protocol acceptance. + +## Identity handling audit + +* `NormalizeUserMID` supports both legacy `u`/`U` + 32 hexadecimal digits and the + observed gateway `U` + 43 URL-safe ASCII characters. Legacy hex normalization + remains; opaque IDs are preserved exactly. +* Lookup input, returned contacts, contact-map keys, cache keys and portal IDs + retain that distinction. The normalization helper is not called by QR login. +* Login verifies the returned profile identity against the login response using + exact equality and preserves its bytes in stored metadata and login ID. +* `makeUserID`/`makePortalID` are string type conversions; send constructs the + recipient from the portal ID without lowercasing it. Lowercase temporaries in + chat classification only inspect prefixes; they do not replace the recipient. +* New mocked lookup-to-send tests verify legacy and opaque recipient bytes at the + outbound RPC boundary. Unsupported identity shape fails without invalidating the + login or creating a portal. Unknown shapes are not accepted merely by length or + prefix: that would undo wrong-recipient safeguards. + +No newly introduced identity transformation explaining QR rejection was found. +The observed QR failure occurs before `QRCodeLoginV2` returns a successful result, +before profile verification or lookup/portal validation runs. This does not prove +that the deployment or earlier diagnostic activity could not have affected remote +session state; the initiating cause of token invalidation remains unknown. + +## Actual shortcomings corrected locally + +1. The diagnostic allowlist omitted `SecondaryQrCodeException`, and did not inspect + its `alertMessage` field. It now recognizes that fixed name and reports alerts + through the existing fixed login-reason allowlist; other alerts are redacted. +2. The existing session-invalidated notice asserted that another client connected. + A `V3_TOKEN_CLIENT_LOGGED_OUT` response does not identify an initiating device. + The notice and internal error now state session invalidation without attributing + it to another client. Invalidation/recovery behavior and key-preservation checks + are unchanged. + +These are diagnostic/user-facing corrections, not a verified fix for iOS PIN +rejection. Regression tests retain strict privacy and authentication-failure checks. + +## Public protocol references (not official LINE documentation) + +Two reverse-engineered definitions agree that **within SecondaryQrCodeException** +code 100 means `INVALID_CONTEXT`, and that the exception contains `alertMessage`: + +* https://github.com/DeachSword/CHRLINE-Thrift/blob/49aed159e08d8db368add63fddc6c63562187068/subs/Types.thrift +* https://github.com/DeachSword/CHRLINE-Thrift/blob/49aed159e08d8db368add63fddc6c63562187068/subs/Exceptions.thrift +* https://github.com/Shivelight/line-messenger-protocol/blob/65681da9301a65e6103ad441718fdc89e5aa46f0/thrift/SecondaryQrCodeServices/enums.thrift +* https://github.com/Shivelight/line-messenger-protocol/blob/65681da9301a65e6103ad441718fdc89e5aa46f0/thrift/SecondaryQrCodeServices/exceptions.thrift + +The live exception class was redacted to `other`; this mapping must therefore not +be represented as a confirmed interpretation of that response yet. Neither the +mapping nor an `INVALID_CONTEXT` label establishes why the context was rejected. +Android-oriented alternate secure-QR implementations are not evidence to change +this iOS/Chrome gateway flow without matching Chrome protocol evidence. + +## Required next observation + +Obtain the phone's exact non-sensitive error after PIN entry and its LINE version. +If another instrumented attempt is authorized, identify the exact fixed exception +class and safe alert category from the bridge's own login request. Do not reuse +the token in a separate client or request credentials/QR images. A controlled +baseline comparison would require explicit deployment approval; no rollback or +additional authentication attempt has been performed as part of this audit. From 596334a5755c257943dbd605de4dcb2e218bea5a Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 22 Oct 2026 13:06:11 -0400 Subject: [PATCH 08/35] line: structured authentication errors and extended cooldown handling Add a typed AuthError model in pkg/line that normalizes LINE provider exceptions into stable categories. QR RPC errors now carry a structured AuthError; the connector uses it to choose user-facing messages, apply per-account cooldowns, and emit sanitized log fields. - Introduce AuthErrorCategory/AuthErrorStage enums and AuthError struct. - Build AuthError from qrRPCError with safe provider-reason/alert handling. - Export QRRPCError alias so callers can access AuthError(). - Map SecondaryQrCodeException codes and TalkException codes to categories. - Classify email login errors into the same categories. - Add 10-minute cooldown for verification-temporarily-unavailable errors. - Log structured auth_error_* fields for QR failures. - Prevent raw provider reason strings from leaking into user-facing email login messages. - Add regression tests for classification, cooldowns, and provider-text redaction. - Make build.sh honor SOURCE_DATE_EPOCH for reproducible builds. - Update QR_REGRESSION_AUDIT.md with structured-error and cooldown docs. --- build.sh | 7 +- pkg/connector/connector.go | 61 ++++- pkg/connector/login_cooldown.go | 185 +++++++++++++++ pkg/connector/login_cooldown_test.go | 238 ++++++++++++++++++++ pkg/connector/login_error_logging_test.go | 21 ++ pkg/connector/loginerrors.go | 193 +++++++++++++++- pkg/connector/qr_failure_test.go | 208 +++++++++++++++++ pkg/line/auth_error.go | 262 ++++++++++++++++++++++ pkg/line/qr.go | 249 +++++++++++++++++++- pkg/line/qr_error_test.go | 101 +++++++++ readme/QR_REGRESSION_AUDIT.md | 64 ++++++ 11 files changed, 1567 insertions(+), 22 deletions(-) create mode 100644 pkg/connector/login_cooldown.go create mode 100644 pkg/connector/login_cooldown_test.go create mode 100644 pkg/connector/qr_failure_test.go create mode 100644 pkg/line/auth_error.go diff --git a/build.sh b/build.sh index 62df5ef3..62a3dbaf 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,9 @@ #!/bin/sh MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }' | head -n1) -GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + BUILD_TIME=$(date -u -Iseconds -d "@$SOURCE_DATE_EPOCH") +else + BUILD_TIME=$(date -Iseconds) +fi +GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=$BUILD_TIME' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" go build -ldflags="$GO_LDFLAGS" ./cmd/matrix-line "$@" diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index ccf3e06f..ab2c3aac 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -481,16 +481,69 @@ func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { event.Msg("LINE login attempt failed") } +// logQRFailure records the sanitized details of a rejected QR login request. +// The mobile app shows no detail for these rejections, so the bridge log is the +// only structured record of what LINE rejected. If the error carries a structured +// AuthError, its safe classification fields are also logged. +func (ll *LineEmailLogin) logQRFailure(err error, flow string) { + if err == nil || ll.User == nil { + return + } + event := ll.User.Log.Warn().Str("login_flow", flow) + + detail, ok := line.QRLoginFailureDetail(err) + if ok { + if detail.Method != "" { + event.Str("qr_method", detail.Method) + } + event.Int("http_status", detail.HTTPStatus) + if detail.ResponseCode != 0 { + event.Int("line_response_code", detail.ResponseCode) + } + if detail.ExceptionType != "" { + event.Str("line_error_name", detail.ExceptionType) + } + if detail.ExceptionCode != 0 { + event.Int("line_error_code", detail.ExceptionCode) + } + if detail.ExceptionLabel != "" { + event.Str("line_error_label", detail.ExceptionLabel) + } + if detail.Reason != "" { + event.Str("line_error_reason", detail.Reason) + } + if detail.Alert != "" { + event.Str("line_error_alert", detail.Alert) + } + } + + var qrErr *line.QRRPCError + if errors.As(err, &qrErr) && qrErr.AuthError() != nil { + ae := qrErr.AuthError() + event.Str("auth_error_flow", ae.Flow). + Str("auth_error_stage", ae.Stage.String()). + Str("auth_error_category", ae.Category.String()) + if ae.ProviderReason != "" { + event.Str("provider_reason", ae.ProviderReason) + } + event.Str("provider_alert_status", string(ae.ProviderAlertStatus)). + Bool("retry_recommended", ae.RetryRecommended). + Bool("temporary", ae.Temporary). + Bool("user_action_required", ae.UserActionRequired). + Bool("bridge_failure", ae.BridgeFailure). + Bool("cooldown_triggered", ae.CooldownTriggered) + } + + event.Msg("LINE QR login attempt failed") +} + func loginErrorReason(err error) string { details := parseLoginErrorDetails(err) reason := details.ErrorReason if reason == "" { reason = details.ErrorMessage } - if isBlockedUserLoginError(reason) { - return loginTooManyAttemptsReason - } - return reason + return safeEmailLoginReason(reason) } func isBlockedUserLoginError(message string) bool { diff --git a/pkg/connector/login_cooldown.go b/pkg/connector/login_cooldown.go new file mode 100644 index 00000000..5e0ef2ee --- /dev/null +++ b/pkg/connector/login_cooldown.go @@ -0,0 +1,185 @@ +package connector + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/beeper/line/pkg/line" +) + +const ( + // loginRateLimitCooldown is how long the bridge stops starting new login + // attempts for an account after LINE rejected a login as rate limited. + // Repeated attempts keep the account locked, so the bridge waits instead + // of retrying. + loginRateLimitCooldown = 15 * time.Minute + // loginRetryLaterCooldown is the shorter wait used when LINE only asked the + // client to come back later. + loginRetryLaterCooldown = 5 * time.Minute + // loginRetryLaterMessage is shown when LINE asked the client to come back + // later instead of naming a rate limit. + loginRetryLaterMessage = "LINE asked us to try the sign-in again later. Please wait a few minutes before trying again." +) + +// loginRateLimitKind classifies how strongly a login rejection looks like an +// account login rate limit. +type loginRateLimitKind int + +const ( + loginRateLimitNone loginRateLimitKind = iota + loginRateLimitLocked + loginRateLimitRetryLater + loginRateLimitVerificationUnavailable +) + +// loginCooldown tracks when each account may try to sign in again. It is +// in-memory on purpose: the limit is enforced by LINE, so the bridge only +// needs to avoid making it worse. +type loginCooldown struct { + mu sync.Mutex + until map[string]time.Time +} + +// block stops new login attempts for the account until the given time. +func (c *loginCooldown) block(userID string, until time.Time) { + if c == nil || userID == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.until == nil { + c.until = make(map[string]time.Time) + } + if current, ok := c.until[userID]; !ok || until.After(current) { + c.until[userID] = until + } +} + +// remaining returns how long the account still has to wait. +func (c *loginCooldown) remaining(userID string) time.Duration { + if c == nil || userID == "" { + return 0 + } + c.mu.Lock() + defer c.mu.Unlock() + until, ok := c.until[userID] + if !ok { + return 0 + } + remaining := time.Until(until) + if remaining <= 0 { + delete(c.until, userID) + return 0 + } + return remaining +} + +// classifyLoginRateLimit reports whether a login rejection is an account login +// rate limit. LINE does not name the condition in every response, so the +// explicit signals are used: HTTP 429, the fixed "too many login attempts" +// reason, or a request to come back later. If the error carries a structured +// AuthError, its category is authoritative. +func classifyLoginRateLimit(err error) loginRateLimitKind { + if err == nil { + return loginRateLimitNone + } + + // Prefer the structured AuthError classification when available. + var ae *line.AuthError + if errors.As(err, &ae) { + switch ae.Category { + case line.AuthErrorTooManyAttempts: + return loginRateLimitLocked + case line.AuthErrorRetryLater: + return loginRateLimitRetryLater + case line.AuthErrorVerificationTemporarilyUnavailable: + return loginRateLimitVerificationUnavailable + default: + return loginRateLimitNone + } + } + + if detail, ok := line.QRLoginFailureDetail(err); ok { + if detail.HTTPStatus == http.StatusTooManyRequests { + return loginRateLimitLocked + } + if isLoginRateLimitText(detail.Reason) || isLoginRateLimitText(detail.Alert) { + return loginRateLimitLocked + } + if detail.ExceptionLabel == "RETRY_LATER" { + return loginRateLimitRetryLater + } + return loginRateLimitNone + } + details := parseLoginErrorDetails(err) + if details.HTTPStatus == http.StatusTooManyRequests { + return loginRateLimitLocked + } + if isLoginRateLimitText(details.ErrorReason) || isLoginRateLimitText(details.ErrorMessage) { + return loginRateLimitLocked + } + if isVerificationTemporarilyUnavailableText(details.ErrorReason) || isVerificationTemporarilyUnavailableText(details.ErrorMessage) { + return loginRateLimitVerificationUnavailable + } + return loginRateLimitNone +} + +func isVerificationTemporarilyUnavailableText(text string) bool { + return strings.EqualFold(strings.TrimSpace(text), "verification is temporarily unavailable") +} + +func isLoginRateLimitText(text string) bool { + switch strings.ToLower(strings.TrimSpace(text)) { + case "too many login attempts", "blocked user": + return true + } + return false +} + +// noteLoginRateLimit records a rate limit rejection so the next attempt waits +// instead of extending the lock. +func (ll *LineEmailLogin) noteLoginRateLimit(err error) { + if ll.cooldown == nil || ll.User == nil { + return + } + now := time.Now() + switch classifyLoginRateLimit(err) { + case loginRateLimitLocked: + ll.cooldown.block(string(ll.User.MXID), now.Add(loginRateLimitCooldown)) + case loginRateLimitRetryLater: + ll.cooldown.block(string(ll.User.MXID), now.Add(loginRetryLaterCooldown)) + case loginRateLimitVerificationUnavailable: + ll.cooldown.block(string(ll.User.MXID), now.Add(verificationTemporarilyUnavailableCooldown)) + } +} + +// loginCooldownError asks the user to wait while the account is still inside a +// rate limit window. +func (ll *LineEmailLogin) loginCooldownError() error { + if ll.cooldown == nil || ll.User == nil { + return nil + } + remaining := ll.cooldown.remaining(string(ll.User.MXID)) + if remaining <= 0 { + return nil + } + return ErrLoginRateLimited.WithMessage( + "LINE is limiting how often this account can sign in. Please try again in %s.", + formatLoginWait(remaining), + ) +} + +func formatLoginWait(remaining time.Duration) string { + minutes := int((remaining + time.Minute - 1) / time.Minute) + if minutes < 1 { + minutes = 1 + } + if minutes == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", minutes) +} diff --git a/pkg/connector/login_cooldown_test.go b/pkg/connector/login_cooldown_test.go new file mode 100644 index 00000000..a8690bae --- /dev/null +++ b/pkg/connector/login_cooldown_test.go @@ -0,0 +1,238 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + + "github.com/beeper/line/pkg/line" +) + +func runQRLoginToRejection(t *testing.T, body string, status int) (*LineConnector, *bridgev2.User, error) { + t.Helper() + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified", "checkPinCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "qrCodeLoginV2": + resp := resolutionResponse(body) + resp.StatusCode = status + return resp, nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User, cooldown: &lc.UserLogin.Bridge.Network.(*LineConnector).loginCooldown}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + if _, err := lq.Wait(ctx); err != nil { + t.Fatal(err) + } + _, err := lq.Wait(ctx) + return lc.UserLogin.Bridge.Network.(*LineConnector), lc.UserLogin.User, err +} + +func TestQRRateLimitIsReportedAndBlocksImmediateRetry(t *testing.T) { + for _, tc := range []struct { + name, body, wantMessage string + status int + }{ + { + name: "retry later", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`, + wantMessage: loginRetryLaterMessage, + status: http.StatusBadRequest, + }, + { + name: "too many attempts", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"reason":"Too many login attempts"}}`, + wantMessage: loginTooManyAttemptsReason, + status: http.StatusBadRequest, + }, + { + name: "http 429", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100}}`, + wantMessage: loginTooManyAttemptsReason, + status: http.StatusTooManyRequests, + }, + } { + t.Run(tc.name, func(t *testing.T) { + connector, user, err := runQRLoginToRejection(t, tc.body, tc.status) + if err == nil { + t.Fatal("rate limited QR login was accepted") + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) { + t.Fatalf("QR rejection is not a user-facing error: %v", err) + } + if respErr.Err != tc.wantMessage { + t.Fatalf("message = %q, want %q", respErr.Err, tc.wantMessage) + } + if respErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429", respErr.StatusCode) + } + connector.loginCooldown.block("other-user", time.Now().Add(time.Hour)) + remaining := connector.loginCooldown.remaining(string(user.MXID)) + if remaining <= 0 { + t.Fatal("rate limit did not start a cooldown") + } + lq := &LineQRLogin{login: &LineEmailLogin{User: user, cooldown: &connector.loginCooldown}} + _, err = lq.Start(context.Background()) + if err == nil { + t.Fatal("a new QR login was started inside the rate limit window") + } + var cooldownErr bridgev2.RespError + if !errors.As(err, &cooldownErr) { + t.Fatalf("cooldown error is not user-facing: %v", err) + } + if !strings.Contains(cooldownErr.Err, "try again in") { + t.Fatalf("cooldown message = %q", cooldownErr.Err) + } + }) + } +} + +func TestVerificationTemporarilyUnavailableBlocksRetry(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, errors.New(`login failed: API error 400: {"code":10051,"data":{"name":"TalkException","reason":"Verification is temporarily unavailable"}}`) + } + logger := zerolog.New(io.Discard) + var cooldown loginCooldown + ll := &LineEmailLogin{ + User: &bridgev2.User{ + User: &database.User{MXID: "@user:example.com"}, + Bridge: &bridgev2.Bridge{Log: logger}, + Log: logger, + }, + cooldown: &cooldown, + } + step, err := ll.SubmitUserInput(context.Background(), map[string]string{ + "email": "user@example.com", + "password": "secret", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step == nil { + t.Fatal("expected error step, got nil") + } + remaining := cooldown.remaining("@user:example.com") + if remaining <= verificationTemporarilyUnavailableCooldown-time.Minute || remaining > verificationTemporarilyUnavailableCooldown { + t.Fatalf("unexpected cooldown remaining: %s", remaining) + } +} + +func TestQRRejectionWithoutRateLimitDoesNotBlockRetry(t *testing.T) { + connector, user, err := runQRLoginToRejection(t, `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":2}}`, http.StatusBadRequest) + if err == nil { + t.Fatal("QR login was accepted") + } + if remaining := connector.loginCooldown.remaining(string(user.MXID)); remaining != 0 { + t.Fatalf("a verification failure started a rate limit cooldown: %s", remaining) + } + lq := &LineQRLogin{login: &LineEmailLogin{User: user, cooldown: &connector.loginCooldown}} + _, err = lq.Start(context.Background()) + if err != nil && strings.Contains(err.Error(), "limiting how often") { + t.Fatalf("unexpected rate limit error: %v", err) + } +} + +func TestEmailLoginRateLimitBlocksNextAttempt(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, errors.New(`login failed: API error 429: {"code":10051,"data":{"name":"TalkException","reason":"Too many login attempts"}}`) + } + logger := zerolog.New(io.Discard) + var cooldown loginCooldown + ll := &LineEmailLogin{ + User: &bridgev2.User{ + User: &database.User{MXID: "@user:example.com"}, + Bridge: &bridgev2.Bridge{Log: logger}, + Log: logger, + }, + cooldown: &cooldown, + } + step, err := ll.SubmitUserInput(context.Background(), map[string]string{ + "email": "user@example.com", + "password": "secret", + }) + if err != nil { + t.Fatal(err) + } + if step == nil || step.Instructions != loginTooManyAttemptsInstructions { + t.Fatalf("step instructions = %v, want %q", step, loginTooManyAttemptsInstructions) + } + if remaining := cooldown.remaining("@user:example.com"); remaining <= 0 { + t.Fatal("rate limited login did not start a cooldown") + } + if _, err := ll.Start(context.Background()); err == nil { + t.Fatal("a new login was started inside the rate limit window") + } else if !strings.Contains(err.Error(), "try again in") { + t.Fatalf("unexpected cooldown error: %v", err) + } +} + +func TestLoginCooldownExpires(t *testing.T) { + var cooldown loginCooldown + cooldown.block("@user:example.com", time.Now().Add(-time.Minute)) + if remaining := cooldown.remaining("@user:example.com"); remaining != 0 { + t.Fatalf("expired cooldown still blocks login: %s", remaining) + } + cooldown.block("@user:example.com", time.Now().Add(time.Minute)) + if remaining := cooldown.remaining("@user:example.com"); remaining <= 0 { + t.Fatal("active cooldown does not block login") + } + if remaining := cooldown.remaining("@other:example.com"); remaining != 0 { + t.Fatalf("cooldown leaked to another account: %s", remaining) + } +} + +func TestLoginRateLimitClassification(t *testing.T) { + for _, tc := range []struct { + name string + err error + want loginRateLimitKind + }{ + {"http 429", errors.New("login failed: API error 429: {\"code\":10051}"), loginRateLimitLocked}, + {"too many attempts", errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"Too many login attempts\"}}"), loginRateLimitLocked}, + {"blocked user", errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"blocked user\"}}"), loginRateLimitLocked}, + {"invalid credentials", errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"Account ID or password is invalid\"}}"), loginRateLimitNone}, + {"unrelated", errors.New("network unreachable"), loginRateLimitNone}, + {"nil", nil, loginRateLimitNone}, + {"verification temporarily unavailable via AuthError", wrapLineLoginError(errors.New(`login failed: API error 400: {"code":10051,"data":{"name":"TalkException","reason":"Verification is temporarily unavailable"}}`)), loginRateLimitVerificationUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := classifyLoginRateLimit(tc.err); got != tc.want { + t.Fatalf("classification = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/connector/login_error_logging_test.go b/pkg/connector/login_error_logging_test.go index fa935776..90b3c8da 100644 --- a/pkg/connector/login_error_logging_test.go +++ b/pkg/connector/login_error_logging_test.go @@ -96,6 +96,27 @@ func TestLoginErrorSummaryDoesNotIncludeNonJSONResponseBody(t *testing.T) { } } +func TestLoginErrorReasonDoesNotLeakUnknownProviderText(t *testing.T) { + for _, tc := range []struct { + reason, want string + }{ + {"blocked user", loginTooManyAttemptsReason}, + {"Too many login attempts", loginTooManyAttemptsReason}, + {"Verification is temporarily unavailable", line.UserMessageForCategory(line.AuthErrorVerificationTemporarilyUnavailable)}, + {"Account ID or password is invalid", "Account ID or password is invalid"}, + {"Authentication failed", "Authentication failed"}, + {"", ""}, + {"response-token secret", ""}, + {"password: hunter2", ""}, + } { + t.Run(tc.reason, func(t *testing.T) { + if got := safeEmailLoginReason(tc.reason); got != tc.want { + t.Fatalf("safeEmailLoginReason(%q) = %q, want %q", tc.reason, got, tc.want) + } + }) + } +} + func TestLoginErrorSummaryAllowsKnownContextErrors(t *testing.T) { err := fmt.Errorf("login failed: %w", context.DeadlineExceeded) details := parseLoginErrorDetails(err) diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 2f9bd1fa..f5ec1e2f 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net/http" + "strings" + "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" @@ -41,30 +43,203 @@ var ( ErrLoginUnknown = bridgev2.RespError(mautrix.MUnknown.WithMessage("Internal error logging in to LINE")) ) -// wrapLineLoginError translates a LINE error into one the client can act on, keeping the -// original in the chain with %w so logs are unaffected. +// verificationTemporarilyUnavailableCooldown is the bridge-side cooldown for +// LINE's "Verification is temporarily unavailable" provider alert. It is a +// bridge policy, not a LINE-provided retry guarantee. +const verificationTemporarilyUnavailableCooldown = 10 * time.Minute + +// intPtr returns a pointer to v when v is non-zero, otherwise nil. +func intPtr(v int) *int { + if v == 0 { + return nil + } + return &v +} + +// safeEmailLoginReason returns a user-facing reason string for known LINE email +// login rejection reasons. It returns the standard rate-limit message for rate +// limits, a fixed message for verification unavailability, and the original +// string only for a small allowlist of safe provider reasons. Unrecognized +// reasons return empty so callers can fall back to generic copy. +func safeEmailLoginReason(reason string) string { + switch strings.ToLower(strings.TrimSpace(reason)) { + case "blocked user", "too many login attempts": + return loginTooManyAttemptsReason + case "verification is temporarily unavailable": + return line.UserMessageForCategory(line.AuthErrorVerificationTemporarilyUnavailable) + case "authentication failed", "account id or password is invalid": + return reason + } + return "" +} + +// wrapLineLoginError translates a LINE error into one the client can act on, +// keeping the original in the chain with %w so logs are unaffected. func wrapLineLoginError(err error) error { if err == nil { return nil } - mapped := ErrLoginUnknown details := parseLoginErrorDetails(err) reason := details.ErrorReason if reason == "" { reason = details.ErrorMessage } + safeReason := safeEmailLoginReason(reason) + cat := classifyEmailError(details, reason) + + mapped := ErrLoginUnknown switch { case errors.Is(err, line.ErrLoginVerificationExpired): mapped = ErrLoginVerificationFailed - case isBlockedUserLoginError(reason): - mapped = ErrLoginTooManyAttempts - case details.HTTPStatus == http.StatusTooManyRequests: + case cat == line.AuthErrorTooManyAttempts: mapped = ErrLoginTooManyAttempts + case cat == line.AuthErrorRetryLater: + mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) + case cat == line.AuthErrorVerificationTemporarilyUnavailable: + mapped = ErrLoginRejected.WithMessage("%s", line.UserMessageForCategory(line.AuthErrorVerificationTemporarilyUnavailable)) case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: mapped = ErrLoginRejected - case reason != "": - // LINE's own reason strings are short and user-facing, so quote them. - mapped = ErrLoginRejected.WithMessage("%s", reason) + case safeReason != "": + mapped = ErrLoginRejected.WithMessage("%s", safeReason) + } + + ae := &line.AuthError{ + Flow: "email", + Stage: line.AuthStageEmailLogin, + HTTPStatus: details.HTTPStatus, + ResponseCode: intPtr(details.ResponseCode), + ExceptionType: details.ErrorName, + ExceptionCode: intPtr(details.ErrorCode), + Category: cat, + ProviderReason: line.SanitizeProviderAlert(reason), + ProviderAlertStatus: line.SafeAlertStatus(details.ErrorReason != ""), + UserMessage: mapped.Err, + RetryRecommended: isRetryableCategory(cat), + Temporary: isTemporaryCategory(cat), + UserActionRequired: true, + BridgeFailure: false, + CooldownTriggered: cat == line.AuthErrorTooManyAttempts || cat == line.AuthErrorRetryLater || cat == line.AuthErrorVerificationTemporarilyUnavailable, + Err: err, + } + return fmt.Errorf("%w: %w", ae, mapped) +} + +// classifyEmailError classifies an email/login error into an AuthErrorCategory. +func classifyEmailError(details loginErrorDetails, reason string) line.AuthErrorCategory { + switch { + case isBlockedUserLoginError(reason): + return line.AuthErrorTooManyAttempts + case isLoginRateLimitText(reason): + return line.AuthErrorTooManyAttempts + case details.HTTPStatus == http.StatusTooManyRequests: + return line.AuthErrorTooManyAttempts + case strings.EqualFold(strings.TrimSpace(reason), "blocked user"): + return line.AuthErrorTooManyAttempts + case strings.EqualFold(strings.TrimSpace(reason), "authentication failed"): + return line.AuthErrorInvalidCredentials + case strings.EqualFold(strings.TrimSpace(reason), "account id or password is invalid"): + return line.AuthErrorInvalidCredentials + case strings.EqualFold(strings.TrimSpace(reason), "verification is temporarily unavailable"): + return line.AuthErrorVerificationTemporarilyUnavailable + case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: + return line.AuthErrorInvalidCredentials + default: + return line.AuthErrorUnknown + } +} + +// isRetryableCategory returns true if the category suggests a retry may succeed. +func isRetryableCategory(cat line.AuthErrorCategory) bool { + switch cat { + case line.AuthErrorTooManyAttempts, line.AuthErrorRetryLater, + line.AuthErrorVerificationTemporarilyUnavailable, line.AuthErrorServiceUnavailable, + line.AuthErrorNetworkUnavailable: + return true + default: + return false + } +} + +// isTemporaryCategory returns true if the category is likely temporary. +func isTemporaryCategory(cat line.AuthErrorCategory) bool { + switch cat { + case line.AuthErrorTooManyAttempts, line.AuthErrorRetryLater, + line.AuthErrorVerificationTemporarilyUnavailable, line.AuthErrorServiceUnavailable: + return true + default: + return false + } +} + +// qrLoginFailureMessage maps a rejected QR request to fixed user-facing copy. +// The mobile app shows no detail for these rejections, so the bridge is the +// only place the reason can be shown. Provider text is never quoted; only fixed +// schema labels are used. +func qrLoginFailureMessage(detail line.QRFailureDetail) string { + switch detail.ExceptionLabel { + case "NOT_ALLOWED_QR_CODE_LOGIN": + return "LINE doesn't allow QR sign-in for this account. Please sign in with your email and password instead." + case "INVALID_CONTEXT": + return "LINE rejected the QR sign-in because the sign-in context was no longer valid. Please start the sign-in again with a fresh QR code." + case "VERIFICATION_FAILED": + return "LINE didn't confirm the QR sign-in. Please scan the QR code again and try once more." + case "VERIFICATION_NOTICE_FAILED": + return "LINE didn't confirm the QR sign-in request. Please try again." + case "APP_UPGRADE_REQUIRED": + return "LINE requires a newer version of the app. Please update the LINE app and try again." + case "ILLEGAL_ARGUMENT": + return "LINE rejected the QR sign-in request. Please start the sign-in again." + } + return "LINE rejected the QR sign-in. Please try again." +} + +// wrapQRRPCError translates a rejected QR request into a user-facing error. +// The original error stays in the chain with %w so logs are unaffected. +// It now preserves the structured AuthError for caller inspection. +func wrapQRRPCError(err error) error { + if err == nil { + return nil + } + var respErr bridgev2.RespError + if errors.As(err, &respErr) { + return err + } + + // Extract the structured AuthError from the underlying QRRPCError, if + // available, to preserve the provider classification and safe alert status. + var qrErr *line.QRRPCError + if errors.As(err, &qrErr) && qrErr.AuthError() != nil { + ae := qrErr.AuthError() + var mapped bridgev2.RespError + switch ae.Category { + case line.AuthErrorTooManyAttempts: + mapped = ErrLoginTooManyAttempts + case line.AuthErrorRetryLater: + mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) + case line.AuthErrorVerificationTemporarilyUnavailable: + mapped = ErrLoginRejected.WithMessage("%s", ae.UserMessage) + default: + mapped = ErrLoginRejected.WithMessage("%s", qrLoginFailureMessage(detail)) + } + return fmt.Errorf("%w: %w", ae, mapped) + } + + var mapped bridgev2.RespError + switch statusCode := line.HTTPStatusCode(err); { + case errors.Is(err, errLineSessionInvalidated), errors.Is(err, errLineClientSuperseded), line.IsAuthError(err): + mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("Not logged in to LINE. Reconnect and try again.")) + case line.IsNotAMemberError(err): + mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("You are not a member of this LINE chat")) + case line.IsTalkExceptionNotFound(err): + mapped = bridgev2.RespError(mautrix.MNotFound.WithMessage("Not found on LINE")) + case statusCode == http.StatusTooManyRequests: + mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("LINE is rate limiting requests. Please wait a moment and try again.").WithStatus(http.StatusBadRequest)) + case errors.Is(err, context.DeadlineExceeded): + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Timed out waiting for LINE")) + case line.IsTalkException(err), statusCode >= 400 && statusCode < 500: + mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("LINE rejected the request")) + default: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Request to LINE failed")) } return fmt.Errorf("%w: %w", mapped, err) } diff --git a/pkg/connector/qr_failure_test.go b/pkg/connector/qr_failure_test.go new file mode 100644 index 00000000..a233e0ed --- /dev/null +++ b/pkg/connector/qr_failure_test.go @@ -0,0 +1,208 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/line/pkg/line" +) + +func TestQRRejectionSurfacesFixedReasonWithoutProviderText(t *testing.T) { + for _, tc := range []struct { + name, body, wantMessage, wantLabel string + wantCode int + }{ + { + name: "invalid context", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"PRIVATE-ALERT"}}`, + wantMessage: "LINE rejected the QR sign-in because the sign-in context was no longer valid. Please start the sign-in again with a fresh QR code.", + wantLabel: "INVALID_CONTEXT", + wantCode: 100, + }, + { + name: "retry later", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`, + wantMessage: loginRetryLaterMessage, + wantLabel: "RETRY_LATER", + wantCode: 5, + }, + { + name: "not allowed", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":3}}`, + wantMessage: "LINE doesn't allow QR sign-in for this account. Please sign in with your email and password instead.", + wantLabel: "NOT_ALLOWED_QR_CODE_LOGIN", + wantCode: 3, + }, + { + name: "unknown class", + body: `{"code":10051,"data":{"name":"PRIVATE-CLASS","code":100,"reason":"PRIVATE-REASON"}}`, + wantMessage: "LINE rejected the QR sign-in. Please try again.", + wantCode: 100, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + logger := zerolog.New(&output) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "checkPinCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "qrCodeLoginV2": + resp := resolutionResponse(tc.body) + resp.StatusCode = http.StatusBadRequest + return resp, nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lc.UserLogin.User.Log = logger + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + if _, err := lq.Wait(ctx); err != nil { + t.Fatal(err) + } + step, err := lq.Wait(ctx) + if step != nil { + t.Fatalf("rejected QR login returned a step: %+v", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) { + t.Fatalf("QR rejection is not a user-facing error: %v", err) + } + if respErr.Err != tc.wantMessage { + t.Fatalf("message = %q, want %q", respErr.Err, tc.wantMessage) + } + for _, secret := range []string{"PRIVATE-ALERT", "PRIVATE-CLASS", "PRIVATE-REASON"} { + if strings.Contains(respErr.Err, secret) || strings.Contains(err.Error(), secret) { + t.Fatalf("provider text leaked into the user-facing error: %v", err) + } + } + var event map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &event); err != nil { + t.Fatalf("failed to parse log event: %v\n%s", err, output.String()) + } + if event["message"] != "LINE QR login attempt failed" || event["login_flow"] != "qr_token_issue" { + t.Fatalf("unexpected log event: %v", event) + } + if event["qr_method"] != "qrCodeLoginV2" || event["line_error_code"] != float64(tc.wantCode) { + t.Fatalf("missing sanitized QR details: %v", event) + } + if tc.wantLabel == "" { + if _, ok := event["line_error_label"]; ok { + t.Fatalf("unknown class received a code label: %v", event) + } + } else if event["line_error_label"] != tc.wantLabel { + t.Fatalf("label = %v, want %q", event["line_error_label"], tc.wantLabel) + } + }) + } +} + +func TestQRFailureDetailIsNotBuiltFromUnrelatedErrors(t *testing.T) { + if detail, ok := line.QRLoginFailureDetail(errors.New("unrelated")); ok || detail.Method != "" { + t.Fatal("unrelated error was reported as a QR failure") + } +} + +func TestQRVerificationTemporarilyUnavailable(t *testing.T) { + var output bytes.Buffer + logger := zerolog.New(&output) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "checkPinCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "qrCodeLoginV2": + resp := resolutionResponse(`{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"Verification is temporarily unavailable"}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lc.UserLogin.User.Log = logger + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + if _, err := lq.Wait(ctx); err != nil { + t.Fatal(err) + } + _, err := lq.Wait(ctx) + if err == nil { + t.Fatal("verification unavailable QR login was accepted") + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) { + t.Fatalf("QR rejection is not a user-facing error: %v", err) + } + + var event map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &event); err != nil { + t.Fatalf("failed to parse log event: %v\n%s", err, output.String()) + } + if event["auth_error_category"] != "VERIFICATION_TEMPORARILY_UNAVAILABLE" { + t.Fatalf("auth_error_category = %v, want VERIFICATION_TEMPORARILY_UNAVAILABLE", event["auth_error_category"]) + } + if event["provider_alert_status"] != "recognized" { + t.Fatalf("provider_alert_status = %v, want recognized", event["provider_alert_status"]) + } + if event["cooldown_triggered"] != true { + t.Fatalf("cooldown_triggered = %v, want true", event["cooldown_triggered"]) + } + if event["retry_recommended"] != true { + t.Fatalf("retry_recommended = %v, want true", event["retry_recommended"]) + } + if event["temporary"] != true { + t.Fatalf("temporary = %v, want true", event["temporary"]) + } + for _, secret := range []string{"Verification is temporarily unavailable"} { + if strings.Contains(respErr.Err, secret) || strings.Contains(err.Error(), secret) { + t.Fatalf("provider text leaked into the user-facing error: %v", err) + } + } +} diff --git a/pkg/line/auth_error.go b/pkg/line/auth_error.go new file mode 100644 index 00000000..7252dac0 --- /dev/null +++ b/pkg/line/auth_error.go @@ -0,0 +1,262 @@ +package line + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// AuthErrorCategory is a normalized classification of an authentication failure. +// It bridges LINE provider-specific exceptions into stable, bridge-side +// categories that drive user-facing messages, cooldowns, and diagnostics. +type AuthErrorCategory int + +const ( + // AuthErrorUnknown is the default category for unrecognized failures. + AuthErrorUnknown AuthErrorCategory = iota + // AuthErrorInvalidCredentials means LINE rejected the supplied credentials. + AuthErrorInvalidCredentials + // AuthErrorVerificationRequired means LINE requires additional verification. + AuthErrorVerificationRequired + // AuthErrorVerificationTemporarilyUnavailable means LINE temporarily cannot + // complete verification (the confirmed "Verification is temporarily + // unavailable" provider alert). + AuthErrorVerificationTemporarilyUnavailable + // AuthErrorTooManyAttempts means LINE rate-limited login attempts. + AuthErrorTooManyAttempts + // AuthErrorRetryLater means LINE asked the client to come back later. + AuthErrorRetryLater + // AuthErrorQRLoginNotAllowed means LINE does not allow QR sign-in for this + // account or session. + AuthErrorQRLoginNotAllowed + // AuthErrorQRContextExpired means the QR sign-in context expired before + // completion (only when explicitly supported by the provider response). + AuthErrorQRContextExpired + // AuthErrorNetworkUnavailable means LINE could not be reached. + AuthErrorNetworkUnavailable + // AuthErrorServiceUnavailable means LINE returned a service error. + AuthErrorServiceUnavailable + // AuthErrorUnsupportedMethod means LINE does not support the requested + // authentication method. + AuthErrorUnsupportedMethod +) + +// String returns a stable schema label for the category. +func (c AuthErrorCategory) String() string { + switch c { + case AuthErrorInvalidCredentials: + return "INVALID_CREDENTIALS" + case AuthErrorVerificationRequired: + return "VERIFICATION_REQUIRED" + case AuthErrorVerificationTemporarilyUnavailable: + return "VERIFICATION_TEMPORARILY_UNAVAILABLE" + case AuthErrorTooManyAttempts: + return "TOO_MANY_ATTEMPTS" + case AuthErrorRetryLater: + return "RETRY_LATER" + case AuthErrorQRLoginNotAllowed: + return "QR_LOGIN_NOT_ALLOWED" + case AuthErrorQRContextExpired: + return "QR_CONTEXT_EXPIRED" + case AuthErrorNetworkUnavailable: + return "NETWORK_UNAVAILABLE" + case AuthErrorServiceUnavailable: + return "SERVICE_UNAVAILABLE" + case AuthErrorUnsupportedMethod: + return "UNSUPPORTED_METHOD" + default: + return "UNKNOWN" + } +} + +// AuthErrorStage identifies the stage of the authentication flow where the +// failure occurred. +type AuthErrorStage int + +const ( + // AuthStageUnknown is the default stage. + AuthStageUnknown AuthErrorStage = iota + // AuthStageQRCreate is the QR session/code creation stage. + AuthStageQRCreate + // AuthStageQRVerify is the QR verification/polling stage. + AuthStageQRVerify + // AuthStageQRTokenIssue is the qrCodeLoginV2 token-issue stage. + AuthStageQRTokenIssue + // AuthStageEmailLogin is the email/password login stage. + AuthStageEmailLogin + // AuthStageSessionEstablish is the session establishment stage. + AuthStageSessionEstablish +) + +// String returns a stable label for the stage. +func (s AuthErrorStage) String() string { + switch s { + case AuthStageQRCreate: + return "qr_create" + case AuthStageQRVerify: + return "qr_verify" + case AuthStageQRTokenIssue: + return "qr_token_issue" + case AuthStageEmailLogin: + return "email_login" + case AuthStageSessionEstablish: + return "session_establish" + default: + return "unknown" + } +} + +// AuthError is a structured authentication error that carries LINE provider +// metadata, normalized classification, and user-facing guidance. It is safe to +// log and to present to operators; user-facing messages are derived separately. +type AuthError struct { + // Flow is the authentication flow (qr or email). + Flow string + // Stage is the authentication stage where the failure occurred. + Stage AuthErrorStage + // HTTPStatus is the HTTP status code returned by LINE, if any. + HTTPStatus int + // ResponseCode is LINE's response code (e.g. 10051), if any. + ResponseCode *int + // ExceptionType is LINE's exception type name, if any. + ExceptionType string + // ExceptionCode is LINE's exception code, if any. + ExceptionCode *int + // Category is the normalized bridge-side classification. + Category AuthErrorCategory + // ProviderReason is a safe, normalized provider reason string, never raw + // provider text. + ProviderReason string + // ProviderAlertStatus describes whether the provider alert was recognized. + ProviderAlertStatus string + // UserMessage is the concise user-facing explanation. + UserMessage string + // RetryRecommended indicates whether retrying is appropriate. + RetryRecommended bool + // RetryAfter is the suggested wait duration before retrying, if known. + RetryAfter *time.Duration + // Temporary indicates whether the failure is likely temporary. + Temporary bool + // UserActionRequired indicates whether the user must take action. + UserActionRequired bool + // BridgeFailure indicates whether the failure originated in the bridge. + BridgeFailure bool + // CooldownTriggered indicates whether a local cooldown was triggered. + CooldownTriggered bool + // Err is the original wrapped error, if any. + Err error +} + +// Error implements the error interface. +func (e *AuthError) Error() string { + if e.UserMessage != "" { + return e.UserMessage + } + return fmt.Sprintf("LINE authentication failed: %s", e.Category.String()) +} + +// Unwrap returns the original wrapped error. +func (e *AuthError) Unwrap() error { + return e.Err +} + +// SafeAlertStatus returns a safe provider alert status string. Never returns +// raw provider text. +func SafeAlertStatus(recognized bool) string { + if recognized { + return "recognized" + } + return "unrecognized" +} + +// SanitizeProviderAlert maps a raw LINE provider alert to a safe category +// string. It never returns raw provider text. An empty alert returns "absent". +func SanitizeProviderAlert(alert string) string { + trimmed := strings.TrimSpace(alert) + if trimmed == "" { + return "absent" + } + lower := strings.ToLower(trimmed) + switch { + case strings.EqualFold(lower, "verification is temporarily unavailable"): + return "verification_temporarily_unavailable" + case strings.EqualFold(lower, "too many login attempts"): + return "too_many_login_attempts" + case strings.EqualFold(lower, "blocked user"): + return "blocked_user" + case strings.EqualFold(lower, "authentication failed"): + return "authentication_failed" + case strings.EqualFold(lower, "failed to issue v3 token"): + return "failed_to_issue_v3_token" + case strings.EqualFold(lower, "account id or password is invalid"): + return "account_id_or_password_is_invalid" + default: + return "unrecognized" + } +} + +// UserMessageForCategory returns the default user-facing message for a +// category. Callers may override with stage-specific wording. +func UserMessageForCategory(c AuthErrorCategory) string { + switch c { + case AuthErrorInvalidCredentials: + return "LINE rejected the email or password. Check your credentials and try again." + case AuthErrorVerificationRequired: + return "LINE requires additional verification. Please complete the verification prompt and try again." + case AuthErrorVerificationTemporarilyUnavailable: + return "LINE verification is temporarily unavailable for this sign-in. Please wait before trying again." + case AuthErrorTooManyAttempts: + return "LINE is temporarily limiting login attempts. Please wait before trying again." + case AuthErrorRetryLater: + return "LINE asked us to try the sign-in again later. Please wait before trying again." + case AuthErrorQRLoginNotAllowed: + return "LINE is not allowing QR-code login for this account or session. Please sign in with your email and password instead." + case AuthErrorQRContextExpired: + return "This QR sign-in has expired. Start a new QR login." + case AuthErrorNetworkUnavailable: + return "Unable to reach LINE right now. Please try again later." + case AuthErrorServiceUnavailable: + return "LINE is temporarily unavailable. Please try again later." + case AuthErrorUnsupportedMethod: + return "LINE does not support this authentication method. Please try a different sign-in method." + default: + return "LINE rejected the login request, but did not provide a recognized reason. Please check the bridge logs or contact your administrator." + } +} + +// IsNetworkError returns true if the error is a network/transport failure. +func IsNetworkError(err error) bool { + if err == nil { + return false + } + var ae *AuthError + if errorsAs(err, &ae) { + switch ae.Category { + case AuthErrorNetworkUnavailable, AuthErrorServiceUnavailable: + return true + } + } + return false +} + +// IsRateLimited returns true if the error is a rate-limit rejection. +func IsRateLimited(err error) bool { + if err == nil { + return false + } + var ae *AuthError + if errorsAs(err, &ae) { + switch ae.Category { + case AuthErrorTooManyAttempts, AuthErrorRetryLater: + return true + } + } + return false +} + +// errorsAs wraps errors.As for *AuthError without exposing it in the public +// API surface beyond what callers need. +func errorsAs(err error, target interface{}) bool { + return errors.As(err, target) +} diff --git a/pkg/line/qr.go b/pkg/line/qr.go index 46ed301f..1c7bf406 100644 --- a/pkg/line/qr.go +++ b/pkg/line/qr.go @@ -95,14 +95,34 @@ func (c *Client) VerifyQRCertificate(ctx context.Context, session, certificate s } type qrRPCError struct { - method string - httpStatus int - code *int - statusCode *int - exceptionCode *int - exceptionType string - reason string - alert string + method string + httpStatus int + code *int + statusCode *int + exceptionCode *int + exceptionType string + exceptionLabel string + reason string + alert string + authError *AuthError +} + +// QRRPCError is the exported alias for qrRPCError, allowing callers to +// type-assert and access the structured AuthError. +type QRRPCError = qrRPCError + +// secondaryQrCodeErrorLabels maps the fixed error codes of +// SecondaryQrCodeException to their schema labels. The codes are only +// interpreted for that exception class, since other classes reuse the numbers. +var secondaryQrCodeErrorLabels = map[int]string{ + 0: "INTERNAL_ERROR", + 1: "ILLEGAL_ARGUMENT", + 2: "VERIFICATION_FAILED", + 3: "NOT_ALLOWED_QR_CODE_LOGIN", + 4: "VERIFICATION_NOTICE_FAILED", + 5: "RETRY_LATER", + 100: "INVALID_CONTEXT", + 101: "APP_UPGRADE_REQUIRED", } func (e *qrRPCError) Error() string { @@ -128,6 +148,213 @@ func (e *qrRPCError) Error() string { return message } +// buildAuthError constructs a structured AuthError from the raw provider +// fields captured before sanitization. It never exposes raw provider text +// through user-facing fields. +func (e *qrRPCError) buildAuthError(rawName string, rawCode *int, rawReason, rawAlert string) *AuthError { + ae := &AuthError{ + Flow: "qr", + Stage: AuthStageQRTokenIssue, + HTTPStatus: e.httpStatus, + ResponseCode: e.code, + ExceptionType: e.exceptionType, + ExceptionCode: e.exceptionCode, + Category: AuthErrorUnknown, + ProviderReason: SanitizeProviderAlert(rawReason), + ProviderAlertStatus: SafeAlertStatus(rawAlert != ""), + UserMessage: UserMessageForCategory(AuthErrorUnknown), + RetryRecommended: false, + Temporary: false, + UserActionRequired: true, + BridgeFailure: false, + CooldownTriggered: false, + Err: e, + } + if ae.ProviderReason == "absent" && rawAlert != "" { + ae.ProviderReason = SanitizeProviderAlert(rawAlert) + } + + switch { + case e.httpStatus == http.StatusTooManyRequests: + ae.Category = AuthErrorTooManyAttempts + ae.RetryRecommended, ae.Temporary, ae.CooldownTriggered = true, true, true + case rawName == "SecondaryQrCodeException" && e.exceptionCode != nil: + ae.Category, ae.RetryRecommended, ae.Temporary, ae.CooldownTriggered = + classifySecondaryQrCode(*e.exceptionCode, rawAlert) + case rawName == "SecondaryQrCodeLoginException" && e.exceptionCode != nil: + ae.Category, ae.RetryRecommended, ae.Temporary, ae.CooldownTriggered = + classifySecondaryQrCode(*e.exceptionCode, rawAlert) + case e.exceptionType == "TalkException" || e.exceptionType == "LoginException": + ae.Category = classifyTalkException(e.exceptionCode, rawReason, rawAlert) + case e.exceptionType == "other": + ae.Category = AuthErrorUnknown + } + + // Reason and alert text can override schema-label classification when they + // unambiguously indicate a rate limit or verification unavailability. + switch { + case SanitizeProviderAlert(rawReason) == "too_many_login_attempts" || SanitizeProviderAlert(rawAlert) == "too_many_login_attempts": + ae.Category = AuthErrorTooManyAttempts + case SanitizeProviderAlert(rawReason) == "blocked_user" || SanitizeProviderAlert(rawAlert) == "blocked_user": + ae.Category = AuthErrorTooManyAttempts + case SanitizeProviderAlert(rawReason) == "verification_temporarily_unavailable" || SanitizeProviderAlert(rawAlert) == "verification_temporarily_unavailable": + ae.Category = AuthErrorVerificationTemporarilyUnavailable + } + + // Apply consistent retry/temporary/cooldown semantics for categories not + // already configured by their classifier. + applyCategoryDefaults(&ae.RetryRecommended, &ae.Temporary, &ae.CooldownTriggered, ae.Category) + + ae.UserMessage = userMessageForStageAndCategory(ae.Stage, ae.Category, rawAlert) + return ae +} + +// applyCategoryDefaults sets RetryRecommended, Temporary, and CooldownTriggered +// from the category when the classifier did not already set them. +func applyCategoryDefaults(retry, temporary, cooldown *bool, cat AuthErrorCategory) { + switch cat { + case AuthErrorTooManyAttempts, AuthErrorRetryLater, AuthErrorVerificationTemporarilyUnavailable, AuthErrorServiceUnavailable: + *retry, *temporary, *cooldown = true, true, true + case AuthErrorVerificationRequired: + *retry, *temporary, *cooldown = true, true, true + case AuthErrorInvalidCredentials, AuthErrorQRLoginNotAllowed, AuthErrorUnsupportedMethod: + *retry, *temporary, *cooldown = false, false, true + case AuthErrorQRContextExpired: + *retry, *temporary, *cooldown = true, false, false + default: + *retry, *temporary, *cooldown = false, false, false + } +} + +// AuthError returns the structured authentication error, or nil if the error +// did not carry structured provider metadata. +func (e *qrRPCError) AuthError() *AuthError { + return e.authError +} + +// classifySecondaryQrCode maps SecondaryQrCodeException exception codes to +// bridge-side categories. Code 100 is intentionally NOT treated as ordinary +// QR expiration by default; it maps to VERIFICATION_TEMPORARILY_UNAVAILABLE +// only when the provider alert confirms that meaning. +func classifySecondaryQrCode(code int, alert string) (AuthErrorCategory, bool, bool, bool) { + switch code { + case 0: + return AuthErrorServiceUnavailable, true, true, true + case 1: + return AuthErrorInvalidCredentials, false, false, true + case 2: + return AuthErrorVerificationRequired, true, true, true + case 3: + return AuthErrorQRLoginNotAllowed, false, false, true + case 4: + return AuthErrorVerificationRequired, true, true, true + case 5: + return AuthErrorRetryLater, true, true, true + case 100: + // Code 100 is INVALID_CONTEXT in the schema label map, but the actual + // provider alert determines the meaning. A confirmed temporary + // verification alert maps accordingly; otherwise use a neutral + // fallback rather than claiming QR expiration. + if SanitizeProviderAlert(alert) == "verification_temporarily_unavailable" { + return AuthErrorVerificationTemporarilyUnavailable, true, true, true + } + return AuthErrorQRContextExpired, true, false, false + case 101: + return AuthErrorUnsupportedMethod, false, false, true + default: + return AuthErrorUnknown, false, false, false + } +} + +// classifyTalkException maps TalkException codes to categories. +func classifyTalkException(code *int, reason, alert string) AuthErrorCategory { + if code == nil { + return AuthErrorUnknown + } + switch *code { + case 1: + return AuthErrorInvalidCredentials + case 83: + return AuthErrorInvalidCredentials + case 99: + return AuthErrorServiceUnavailable + default: + if SanitizeProviderAlert(reason) == "too_many_login_attempts" || + SanitizeProviderAlert(alert) == "too_many_login_attempts" { + return AuthErrorTooManyAttempts + } + return AuthErrorUnknown + } +} + +// userMessageForStageAndCategory returns a stage-aware user-facing message. +func userMessageForStageAndCategory(stage AuthErrorStage, cat AuthErrorCategory, alert string) string { + switch cat { + case AuthErrorVerificationTemporarilyUnavailable: + return "LINE verification is temporarily unavailable for this sign-in. Please wait before trying again." + case AuthErrorTooManyAttempts: + return "LINE is temporarily limiting login attempts. Please wait before trying again." + case AuthErrorRetryLater: + return "LINE asked us to try the sign-in again later. Please wait before trying again." + case AuthErrorQRLoginNotAllowed: + return "LINE is not allowing QR-code login for this account or session. Please sign in with your email and password instead." + case AuthErrorQRContextExpired: + return "This QR sign-in has expired. Start a new QR login." + case AuthErrorInvalidCredentials: + return "LINE rejected the sign-in credentials. Check your credentials and try again." + case AuthErrorVerificationRequired: + return "LINE requires additional verification. Please complete the verification prompt and try again." + case AuthErrorServiceUnavailable: + return "LINE is temporarily unavailable. Please try again later." + case AuthErrorUnsupportedMethod: + return "LINE does not support this authentication method. Please try a different sign-in method." + case AuthErrorNetworkUnavailable: + return "Unable to reach LINE right now. Please try again later." + default: + if stage == AuthStageQRTokenIssue { + return "LINE rejected the QR sign-in request. Please check the bridge logs or contact your administrator." + } + return "LINE rejected the login request, but did not provide a recognized reason. Please check the bridge logs or contact your administrator." + } +} + +// QRFailureDetail describes a rejected QR login request. Every field is a fixed +// schema label or an allowlisted category, never raw provider text. +type QRFailureDetail struct { + Method string + HTTPStatus int + ResponseCode int + ExceptionType string + ExceptionCode int + ExceptionLabel string + Reason string + Alert string +} + +// QRLoginFailureDetail extracts the sanitized details of a rejected QR login +// request. It reports false for errors that did not come from a QR request. +func QRLoginFailureDetail(err error) (QRFailureDetail, bool) { + var response *qrRPCError + if !errors.As(err, &response) { + return QRFailureDetail{}, false + } + detail := QRFailureDetail{ + Method: response.method, + HTTPStatus: response.httpStatus, + ExceptionType: response.exceptionType, + ExceptionLabel: response.exceptionLabel, + Reason: response.reason, + Alert: response.alert, + } + if response.code != nil { + detail.ResponseCode = *response.code + } + if response.exceptionCode != nil { + detail.ExceptionCode = *response.exceptionCode + } + return detail, true +} + func IsQRLoginPollExpired(err error) bool { var response *qrRPCError return errors.As(err, &response) && response.code != nil && *response.code == 10052 && response.statusCode != nil && *response.statusCode == 410 @@ -259,6 +486,12 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime if details.Alert != "" && response.alert == "" { response.alert = "redacted" } + if details.Name == "SecondaryQrCodeException" && details.Code != nil { + response.exceptionLabel = secondaryQrCodeErrorLabels[*details.Code] + } + response.authError = response.buildAuthError( + details.Name, details.Code, details.Reason, details.Alert, + ) } return response } diff --git a/pkg/line/qr_error_test.go b/pkg/line/qr_error_test.go index 3075f4db..feab6b7e 100644 --- a/pkg/line/qr_error_test.go +++ b/pkg/line/qr_error_test.go @@ -59,3 +59,104 @@ func TestQRErrorDetailsAndRedaction(t *testing.T) { }) } } + +func TestQRLoginFailureDetailIgnoresOtherErrors(t *testing.T) { + detail, ok := QRLoginFailureDetail(errors.New("not a QR error")) + if ok || detail.Method != "" { + t.Fatal("unrelated error was reported as a QR failure") + } +} + +func TestQRRPCErrorAuthError(t *testing.T) { + for _, tc := range []struct { + name string + body string + status int + wantCategory AuthErrorCategory + wantCooldownTriggered bool + wantRetryRecommended bool + wantProviderAlertStatus string + wantProviderReason string + }{ + { + name: "HTTP 429", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100}}`, + status: http.StatusTooManyRequests, + wantCategory: AuthErrorTooManyAttempts, + wantCooldownTriggered: true, + wantRetryRecommended: true, + wantProviderAlertStatus: "unrecognized", + wantProviderReason: "absent", + }, + { + name: "retry later", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`, + status: http.StatusBadRequest, + wantCategory: AuthErrorRetryLater, + wantCooldownTriggered: true, + wantRetryRecommended: true, + wantProviderAlertStatus: "unrecognized", + wantProviderReason: "absent", + }, + { + name: "verification temporarily unavailable", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"Verification is temporarily unavailable"}}`, + status: http.StatusBadRequest, + wantCategory: AuthErrorVerificationTemporarilyUnavailable, + wantCooldownTriggered: true, + wantRetryRecommended: true, + wantProviderAlertStatus: "recognized", + wantProviderReason: "verification_temporarily_unavailable", + }, + { + name: "QR login not allowed", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":3}}`, + status: http.StatusBadRequest, + wantCategory: AuthErrorQRLoginNotAllowed, + wantCooldownTriggered: true, + wantRetryRecommended: false, + wantProviderAlertStatus: "unrecognized", + wantProviderReason: "absent", + }, + } { + t.Run(tc.name, func(t *testing.T) { + runner, err := gen.NewRunner() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + client := NewClient("") + client.LoginAttempt = &LoginAttempt{Runner: runner, Context: ctx, cancel: cancel} + defer client.LoginAttempt.Close() + client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: tc.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.body))}, nil + }) + _, err = client.QRCodeLoginV2(ctx, "session") + cancel() + + var qrErr *QRRPCError + if !errors.As(err, &qrErr) { + t.Fatalf("error is not a QRRPCError: %v", err) + } + ae := qrErr.AuthError() + if ae == nil { + t.Fatal("AuthError was nil") + } + if ae.Category != tc.wantCategory { + t.Fatalf("category = %v, want %v", ae.Category, tc.wantCategory) + } + if ae.CooldownTriggered != tc.wantCooldownTriggered { + t.Fatalf("cooldownTriggered = %v, want %v", ae.CooldownTriggered, tc.wantCooldownTriggered) + } + if ae.RetryRecommended != tc.wantRetryRecommended { + t.Fatalf("retryRecommended = %v, want %v", ae.RetryRecommended, tc.wantRetryRecommended) + } + if ae.ProviderAlertStatus != tc.wantProviderAlertStatus { + t.Fatalf("providerAlertStatus = %q, want %q", ae.ProviderAlertStatus, tc.wantProviderAlertStatus) + } + if ae.ProviderReason != tc.wantProviderReason { + t.Fatalf("providerReason = %q, want %q", ae.ProviderReason, tc.wantProviderReason) + } + }) + } +} diff --git a/readme/QR_REGRESSION_AUDIT.md b/readme/QR_REGRESSION_AUDIT.md index 0464d21b..8447e64f 100644 --- a/readme/QR_REGRESSION_AUDIT.md +++ b/readme/QR_REGRESSION_AUDIT.md @@ -57,6 +57,70 @@ before profile verification or lookup/portal validation runs. This does not prov that the deployment or earlier diagnostic activity could not have affected remote session state; the initiating cause of token invalidation remains unknown. +## Account login rate limits + +The operator observed that the affected account could not complete a QR login +while the account had not been used for login attempts that day, and that a +later attempt succeeded. That is consistent with an account-level login rate +limit rather than a protocol or identity defect, and it matches the repeated +attempts made during this troubleshooting session. The hypothesis is not proven: +the rejected response reported a nested exception code of 100, which the public +`SecondaryQrCodeException` enum names `INVALID_CONTEXT`, and the response did +not carry an explicit rate-limit reason. + +The bridge now handles the rate limit explicitly instead of failing opaquely: + +* Rate limits are recognised from the signals LINE actually returns: HTTP 429, + the fixed "too many login attempts" reason, and the fixed `RETRY_LATER` code + of `SecondaryQrCodeException`. A verification or credential failure is not + treated as a rate limit. +* A rate-limited QR login is reported to the user as the standard + "Too many login attempts" error, and a "retry later" rejection as a request + to wait a few minutes, instead of a generic failure. +* After a rate-limited rejection the bridge stops starting new login attempts + for that account for 15 minutes (5 minutes for a "retry later" rejection) and + tells the user when to come back. Repeated attempts keep the account locked, + so the bridge waits instead of retrying. The wait is in-memory and per + account, and it is cleared once it expires. +* The same handling applies to the email login flow, because the limit is + enforced per account rather than per sign-in method. The automatic reconnect + after a bridge restart still makes a single attempt, because that is the only + way to restore a saved session, and it is reported the same way if the + account is still locked. + +The cooldown lengths are conservative defaults, not measured LINE values. The +bridge cannot know how long the account stays locked, so it waits and reports +the remaining time instead of guessing an exact unlock moment. + +## Structured authentication errors + +LINE login failures are now normalized into a structured `AuthError` model in +`pkg/line/auth_error.go` before they reach connector logic: + +* Provider-specific exception names, codes, reasons and alerts are classified + into stable categories such as `TOO_MANY_ATTEMPTS`, `RETRY_LATER`, + `VERIFICATION_TEMPORARILY_UNAVAILABLE`, `INVALID_CONTEXT`/`QR_CONTEXT_EXPIRED`, + `QR_LOGIN_NOT_ALLOWED`, etc. +* Provider text is never exposed directly. Fixed schema labels and a small + allowlist of safe reason strings are used; everything else is recorded as + `unrecognized`/`redacted`. +* Each `AuthError` carries flags (`retry_recommended`, `temporary`, + `user_action_required`, `bridge_failure`, `cooldown_triggered`) and a safe + `provider_alert_status` (`recognized`, `unrecognized`, `absent`). +* The connector uses these categories to pick user-facing messages, decide + whether a cooldown applies, and emit structured log fields. Raw error strings + still appear in logs only at the original wrapped error level. + +## Extended cooldown handling + +A confirmed "Verification is temporarily unavailable" alert now triggers its +own cooldown category (`loginRateLimitVerificationUnavailable`) with a +10-minute bridge-side wait. This prevents rapid retry loops when LINE indicates +verification is temporarily unavailable, while still allowing a fresh attempt +after the cooldown expires. The category is detected both from the structured +`AuthError` and, as a fallback, from the raw provider reason text during email +login. + ## Actual shortcomings corrected locally 1. The diagnostic allowlist omitted `SecondaryQrCodeException`, and did not inspect From ffb62b50d9c3a2bdf3bbf14d8471159e5a68f575 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 22 Oct 2026 13:07:10 -0400 Subject: [PATCH 09/35] build: place extra build flags before package path Allow build.sh to be used with -o and other go build flags that must appear before the package argument. --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 62a3dbaf..70699783 100755 --- a/build.sh +++ b/build.sh @@ -6,4 +6,4 @@ else BUILD_TIME=$(date -Iseconds) fi GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=$BUILD_TIME' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags="$GO_LDFLAGS" ./cmd/matrix-line "$@" +go build -ldflags="$GO_LDFLAGS" "$@" ./cmd/matrix-line From 00a6ccc9d4bb36dcb07140b95c24887df0e2e40e Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Wed, 21 Oct 2026 00:00:26 -0400 Subject: [PATCH 10/35] test: establish native heap pressure without redundant key decoding Expose the existing native malloc/free alongside ModuleMem for bounded resource fixtures. Prefill the real signing heap, then retain the original real key-allocation abort, recovery, signature and E2EE-isolation assertions. Keep the 6000-signature and 1800-decryption stress loops unchanged. Add allocation isolation, exhaustion and coalescing/reuse tests; generated crypto and default runtime behavior are unchanged. --- pkg/ltsm/memory.go | 19 ++++++++++++++ pkg/ltsm/memory_test.go | 55 +++++++++++++++++++++++++++++++++++++++ pkg/runner_secret_test.go | 17 ++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 pkg/ltsm/memory.go create mode 100644 pkg/ltsm/memory_test.go diff --git a/pkg/ltsm/memory.go b/pkg/ltsm/memory.go new file mode 100644 index 00000000..40565230 --- /dev/null +++ b/pkg/ltsm/memory.go @@ -0,0 +1,19 @@ +package ltsm + +// AllocMemory allocates bytes from the module's native linear-memory heap and +// returns their offset in ModuleMem. It returns zero if the fixed heap cannot +// satisfy the allocation. Like the other low-level Runtime methods, callers +// must serialize access to a runtime. +// +// This complements ModuleMem for resource-pressure fixtures: tests can reserve +// real native allocations without repeatedly decoding cryptographic keys. It +// does not replace the allocator, resize the heap, or synthesize an abort. +func (rt *Runtime) AllocMemory(size uint32) uint32 { + return rt.imp.malloc(size) +} + +// FreeMemory releases an allocation made by AllocMemory on this runtime. The +// caller must not use the allocation afterwards or free it more than once. +func (rt *Runtime) FreeMemory(ptr uint32) { + rt.imp.free(ptr) +} diff --git a/pkg/ltsm/memory_test.go b/pkg/ltsm/memory_test.go new file mode 100644 index 00000000..fc74bed5 --- /dev/null +++ b/pkg/ltsm/memory_test.go @@ -0,0 +1,55 @@ +package ltsm + +import "testing" + +func TestRuntimeNativeMemoryAllocation(t *testing.T) { + rt, err := NewRuntime() + if err != nil { + t.Fatal(err) + } + t.Cleanup(rt.Close) + const size = 64 * 1024 + first, second := rt.AllocMemory(size), rt.AllocMemory(size) + if first == 0 || second == 0 || uint64(first)+size > uint64(len(rt.ModuleMem())) || uint64(second)+size > uint64(len(rt.ModuleMem())) { + t.Fatalf("invalid native allocations: %d, %d", first, second) + } + if first < second+size && second < first+size { + t.Fatal("native allocations overlap") + } + rt.ModuleMem()[first] = 42 + rt.ModuleMem()[second] = 17 + if rt.ModuleMem()[first] != 42 { + t.Fatal("writing one allocation corrupted another") + } + rt.FreeMemory(first) + rt.FreeMemory(second) +} + +func TestRuntimeNativeMemoryExhaustionAndReuse(t *testing.T) { + rt, err := NewRuntime() + if err != nil { + t.Fatal(err) + } + t.Cleanup(rt.Close) + const size = 64 * 1024 + var allocated []uint32 + for range len(rt.ModuleMem()) / size { + ptr := rt.AllocMemory(size) + if ptr == 0 { + break + } + allocated = append(allocated, ptr) + } + if len(allocated) == 0 || rt.AllocMemory(size) != 0 { + t.Fatal("native allocator did not report fixed-heap exhaustion") + } + for _, ptr := range allocated { + rt.FreeMemory(ptr) + } + // Coalescing the freed blocks must make a large real allocation possible. + ptr := rt.AllocMemory(uint32(len(allocated) * size)) + if ptr == 0 { + t.Fatal("freed native memory was not reusable") + } + rt.FreeMemory(ptr) +} diff --git a/pkg/runner_secret_test.go b/pkg/runner_secret_test.go index 45127d0f..42f0a34f 100644 --- a/pkg/runner_secret_test.go +++ b/pkg/runner_secret_test.go @@ -252,6 +252,23 @@ func TestGetSignatureRecoversFromExhaustedRuntime(t *testing.T) { oldRuntime := r.signingRT e2eeMemory := bytes.Clone(r.rt.ModuleMem()) + // Establish heap pressure with the real native allocator, rather than + // spending most of the test repeatedly decoding the same token. Keep the + // final real key allocations and the original abort/recovery assertions: + // malloc exhaustion alone must not be mistaken for a cryptographic abort. + const reserveSize = 64 * 1024 + reserved := 0 + for range len(oldRuntime.ModuleMem()) / reserveSize { + if oldRuntime.AllocMemory(reserveSize) == 0 { + break + } + reserved += reserveSize + } + if reserved == 0 { + t.Fatal("failed to establish native heap pressure") + } + t.Logf("reserved %d native heap bytes before real key allocation", reserved) + // Retain native keys until the fixed WASM heap is exhausted, so the next // cache miss exercises real allocator failure during key derivation. func() { From c0121cf77878060c5fe4b238b39ffed663967f72 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Wed, 21 Oct 2026 00:00:26 -0400 Subject: [PATCH 11/35] test: make concurrency checks deterministic and isolate vector output Use synctest quiescence to check the album-preview worker limit without scheduler-dependent sleeping. Keep ordering and concurrency assertions and bounded failure guards. Write crypto vectors to t.TempDir with mandatory write checks and test-scoped logging, enabling safe parallel execution of the two independent vector tests. --- .../handlers/post_notification_test.go | 10 ++++++- pkg/ltsm/vector_test.go | 27 ++++++++++--------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/pkg/connector/handlers/post_notification_test.go b/pkg/connector/handlers/post_notification_test.go index dce063f8..c54eeee1 100644 --- a/pkg/connector/handlers/post_notification_test.go +++ b/pkg/connector/handlers/post_notification_test.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "maunium.net/go/mautrix/bridgev2" @@ -177,6 +178,10 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { } func TestConvertPostNotificationProcessesAlbumPreviewsConcurrentlyInOrder(t *testing.T) { + synctest.Test(t, testAlbumPreviewConcurrencyAndOrder) +} + +func testAlbumPreviewConcurrencyAndOrder(t *testing.T) { const previewCount = 6 matrix := &postNotificationTestMatrix{} @@ -248,10 +253,13 @@ func TestConvertPostNotificationProcessesAlbumPreviewsConcurrentlyInOrder(t *tes if len(initialStarted) != albumPreviewWorkerLimit { t.Fatalf("initial workers started %d unique previews, want %d", len(initialStarted), albumPreviewWorkerLimit) } + // Quiescence gives every worker a scheduling opportunity before asserting + // that no fifth download can start while all four slots are occupied. + synctest.Wait() select { case oid := <-started: t.Fatalf("preview %q started before a worker slot was released", oid) - case <-time.After(100 * time.Millisecond): + default: } close(release["oid-4"]) diff --git a/pkg/ltsm/vector_test.go b/pkg/ltsm/vector_test.go index bbd8a0dd..18fd881e 100644 --- a/pkg/ltsm/vector_test.go +++ b/pkg/ltsm/vector_test.go @@ -7,8 +7,8 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" - "fmt" "os" + "path/filepath" "testing" "golang.org/x/crypto/curve25519" @@ -17,6 +17,7 @@ import ( // TestGenerateV2TestVector generates a complete V2 encryption test vector // with all intermediate values exposed for cross-validation. func TestGenerateV2TestVector(t *testing.T) { + t.Parallel() // Use deterministic keys so the test vector is reproducible senderPriv, _ := hex.DecodeString("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") receiverPriv, _ := hex.DecodeString("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") @@ -196,17 +197,13 @@ func TestGenerateV2TestVector(t *testing.T) { jsonBytes, _ := json.MarshalIndent(vec, "", " ") t.Logf("\n--- JSON Test Vector ---\n%s", string(jsonBytes)) - // Write to file - if err := os.WriteFile("/tmp/ltsm_v2_test_vector.json", jsonBytes, 0644); err != nil { - t.Logf("Warning: could not write test vector file: %v", err) - } else { - t.Logf("Test vector written to /tmp/ltsm_v2_test_vector.json") - } + writeTestVector(t, "ltsm_v2_test_vector.json", jsonBytes) } // TestGenerateV2DeterministicVector generates a V2 vector with a deterministic // salt and nonce (by encrypting manually) for truly reproducible test vectors. func TestGenerateV2DeterministicVector(t *testing.T) { + t.Parallel() // Deterministic keys senderPriv, _ := hex.DecodeString("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") receiverPriv, _ := hex.DecodeString("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") @@ -367,12 +364,16 @@ func TestGenerateV2DeterministicVector(t *testing.T) { } jsonBytes, _ := json.MarshalIndent(vec, "", " ") - fmt.Println(string(jsonBytes)) + t.Logf("\n--- JSON Test Vector ---\n%s", jsonBytes) + + writeTestVector(t, "ltsm_v2_deterministic_vector.json", jsonBytes) +} - // Write to file - if err := os.WriteFile("/tmp/ltsm_v2_deterministic_vector.json", jsonBytes, 0644); err != nil { - t.Logf("Warning: could not write test vector file: %v", err) - } else { - t.Logf("Test vector written to /tmp/ltsm_v2_deterministic_vector.json") +func writeTestVector(t *testing.T, name string, data []byte) { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("write test vector: %v", err) } + t.Logf("Test vector written to %s (JSON is also retained in test output)", path) } From c4ffe53ff2a23ee851fd49eeefcf7ab9f0840636 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 17:11:02 -0400 Subject: [PATCH 12/35] fix: preserve outbound cancellation and LINE send errors Pass the Matrix request context to sendMessage, preserve structured TalkException details for existing recovery logic, and reject missing acknowledgements instead of returning success or dereferencing nil. Add correlated send-stage logs without message bodies and regressions for ownership, canonical recipients, API failures and cancellation. These defects are reproduced locally; the reported live Element delivery failure still needs event-level evidence. --- pkg/connector/outbound_pipeline_test.go | 119 ++++++++++++++++++++++++ pkg/connector/send_message.go | 15 ++- pkg/line/methods.go | 13 ++- pkg/line/send_message_response_test.go | 34 +++++++ 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 pkg/connector/outbound_pipeline_test.go create mode 100644 pkg/line/send_message_response_test.go diff --git a/pkg/connector/outbound_pipeline_test.go b/pkg/connector/outbound_pipeline_test.go new file mode 100644 index 00000000..2970f5fc --- /dev/null +++ b/pkg/connector/outbound_pipeline_test.go @@ -0,0 +1,119 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + + "github.com/beeper/line/pkg/line" +) + +func outboundTestMessage(t *testing.T, lc *LineClient) *bridgev2.MatrixMessage { + t.Helper() + portal, err := lc.UserLogin.Bridge.GetPortalByKey(context.Background(), networkid.PortalKey{ID: resolutionMID, Receiver: lc.UserLogin.ID}) + if err != nil { + t.Fatal(err) + } + return &bridgev2.MatrixMessage{MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Portal: portal, + Event: &event.Event{ID: "$test-outbound", Sender: lc.UserLogin.UserMXID, RoomID: "!test:example.com"}, + Content: &event.MessageEventContent{MsgType: event.MsgText, Body: "test message"}, + }} +} + +func TestOutboundOwnerAndCanonicalRecipient(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(fmt.Sprint(fail), func(t *testing.T) { + requests := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + requests++ + if !strings.HasSuffix(req.URL.Path, "/sendMessage") { + return nil, fmt.Errorf("unexpected endpoint: %s", req.URL.Path) + } + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 2 { + t.Fatalf("invalid send args: %v %v", args, err) + } + var sent line.Message + if err := json.Unmarshal(args[1], &sent); err != nil { + t.Fatal(err) + } + if sent.To != resolutionMID || sent.From != resolutionOwnMID || sent.Text != "test message" { + t.Fatalf("misrouted/incorrect message: %+v", sent) + } + if fail { + return resolutionResponse(`{"code":42,"message":"send rejected"}`), nil + } + return resolutionResponse(`{"code":0,"data":{"id":"line-message-id"}}`), nil + }) + msg := outboundTestMessage(t, lc) + login, _, err := msg.Portal.FindPreferredLogin(context.Background(), lc.UserLogin.User, true) + if err != nil || login != lc.UserLogin { + t.Fatalf("owner login association: %v", err) + } + other := &bridgev2.User{User: &database.User{MXID: "@other:example.com"}, Bridge: lc.UserLogin.Bridge} + if _, _, err = msg.Portal.FindPreferredLogin(context.Background(), other, true); !errors.Is(err, bridgev2.ErrNotLoggedIn) { + t.Fatalf("unrelated sender was allowed to use owner's login: %v", err) + } + resp, err := lc.HandleMatrixMessage(context.Background(), msg) + if fail { + if err == nil || resp != nil { + t.Fatalf("rejected send reported success: %+v %v", resp, err) + } + } else if err != nil || resp == nil || resp.DB.ID != "line-message-id" { + t.Fatalf("successful send lost LINE acknowledgment: %+v %v", resp, err) + } + if requests != 1 { + t.Fatalf("send RPCs=%d", requests) + } + }) + } +} + +func TestOutboundRequestCancellation(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + close(started) + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-release: + return nil, context.Canceled + } + }) + msg := outboundTestMessage(t, lc) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := lc.HandleMatrixMessage(ctx, msg); done <- err }() + select { + case <-started: + case <-time.After(30 * time.Second): + cancel() + close(release) + <-done + t.Fatal("send did not start") + } + cancel() + select { + case err := <-done: + close(release) + if !errors.Is(err, context.Canceled) { + t.Fatalf("send returned %v", err) + } + case <-time.After(time.Second): + close(release) + <-done + t.Fatal("LINE send did not inherit Matrix request cancellation") + } +} diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index fa2754ef..0e759342 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -64,6 +64,14 @@ func lineGroupE2EEFetchFailureError(err error) error { } func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + logContext := lc.UserLogin.Bridge.Log.With(). + Str("line_login_id", string(lc.UserLogin.ID)). + Str("line_recipient", string(msg.Portal.ID)) + if msg.Event != nil { + logContext = logContext.Stringer("matrix_event_id", msg.Event.ID).Stringer("matrix_sender", msg.Event.Sender) + } + log := logContext.Logger() + log.Debug().Msg("Handling outbound Matrix message for LINE") if err := lc.checkLineCall(ctx); err != nil { return nil, err } @@ -83,8 +91,13 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat var err error var sentMsg *line.Message client, sentMsg, err = callLineResultUsing(lc, ctx, client, func(client *line.Client) (*line.Message, error) { - return client.SendMessage(int64(reqSeq), lineMsg) + return client.SendMessageContext(ctx, int64(reqSeq), lineMsg) }) + resultLog := log.Debug().Bool("success", err == nil) + if sentMsg != nil { + resultLog = resultLog.Str("line_message_id", sentMsg.ID) + } + resultLog.Msg("Finished LINE API send attempt") return sentMsg, err } portalMid := string(msg.Portal.ID) diff --git a/pkg/line/methods.go b/pkg/line/methods.go index ac67286f..39686a73 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -391,7 +391,11 @@ func (c *Client) GetE2EEPublicKey(mid string, keyVersion, keyID int) (*E2EEPubli } func (c *Client) SendMessage(reqSeq int64, msg *Message) (*Message, error) { - resp, err := c.callRPC("TalkService", "sendMessage", reqSeq, msg) + return c.SendMessageContext(c.requestContext(), reqSeq, msg) +} + +func (c *Client) SendMessageContext(ctx context.Context, reqSeq int64, msg *Message) (*Message, error) { + resp, err := c.callRPCContext(ctx, "TalkService", "sendMessage", reqSeq, msg) if err != nil { return nil, err } @@ -404,7 +408,12 @@ func (c *Client) SendMessage(reqSeq int64, msg *Message) (*Message, error) { return nil, err } if wrapper.Code != 0 { - return nil, fmt.Errorf("sendMessage failed: %s", wrapper.Message) + // Keep TalkException codes available to auth recovery and send retries, + // including when LINE wraps an error in an HTTP 200 response. + return nil, fmt.Errorf("sendMessage failed: %s", resp) + } + if wrapper.Data == nil || wrapper.Data.ID == "" { + return nil, fmt.Errorf("sendMessage returned no message acknowledgement") } return wrapper.Data, nil } diff --git a/pkg/line/send_message_response_test.go b/pkg/line/send_message_response_test.go new file mode 100644 index 00000000..fff1be9b --- /dev/null +++ b/pkg/line/send_message_response_test.go @@ -0,0 +1,34 @@ +package line + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestSendMessageRejectsMissingAcknowledgement(t *testing.T) { + for _, body := range []string{`{"code":0}`, `{"code":0,"data":null}`, `{"code":0,"data":{}}`} { + t.Run(body, func(t *testing.T) { + client := NewClient("test-token") + client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + msg, err := client.SendMessage(1, &Message{To: "u0123456789abcdef0123456789abcdef", Text: "test"}) + if err == nil || msg != nil { + t.Fatalf("missing acknowledgement accepted: %+v, %v", msg, err) + } + }) + } +} + +func TestSendMessagePreservesAuthenticationError(t *testing.T) { + client := NewClient("test-token") + client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":119,"reason":"Access token refresh required"}}`))}, nil + }) + _, err := client.SendMessage(1, &Message{To: "u0123456789abcdef0123456789abcdef", Text: "test"}) + if !IsAuthError(err) { + t.Fatalf("send error lost auth classification: %v", err) + } +} From c6cf461d238c7f8ff3c2e53ea9ca4937b8fafa24 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:11:18 -0400 Subject: [PATCH 13/35] fix: preserve safe LINE authentication details and enforce cooldowns --- pkg/connector/connector.go | 71 +++++++++----- pkg/connector/login_cooldown.go | 12 +++ pkg/connector/login_cooldown_test.go | 47 +++++++++ pkg/connector/login_error_logging_test.go | 10 +- pkg/connector/login_qr.go | 25 ++++- pkg/connector/loginerrors.go | 110 +++++++++++++--------- pkg/connector/qr_failure_test.go | 13 +-- pkg/line/auth_error.go | 75 +++++++++++++-- pkg/line/auth_error_test.go | 25 +++++ pkg/line/qr.go | 100 +++++++++++++++----- pkg/line/qr_error_test.go | 56 +++++++++-- 11 files changed, 418 insertions(+), 126 deletions(-) create mode 100644 pkg/line/auth_error_test.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index ab2c3aac..95f804c8 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -32,6 +32,7 @@ type LineConnector struct { br *bridgev2.Bridge loginFinalizeMu sync.Mutex directMedia atomic.Bool + loginCooldown loginCooldown } var _ bridgev2.NetworkConnector = (*LineConnector)(nil) @@ -183,12 +184,12 @@ func (lc *LineConnector) GetLoginFlows() []bridgev2.LoginFlow { func (lc *LineConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { if flowID == LoginFlowIDQR && lc.Config.QRLogin { - return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu}}, nil + return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, cooldown: &lc.loginCooldown}}, nil } if flowID != LoginFlowIDEmail { return nil, bridgev2.ErrInvalidLoginFlowID } - return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin}, nil + return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin, cooldown: &lc.loginCooldown}, nil } type LineEmailLogin struct { @@ -204,6 +205,7 @@ type LineEmailLogin struct { ExistingLogin *bridgev2.UserLogin qrEnabled bool qrLogin *LineQRLogin + cooldown *loginCooldown pollResult chan *line.LoginResult pollErr chan error @@ -221,6 +223,9 @@ var _ bridgev2.LoginProcessDisplayAndWait = (*LineEmailLogin)(nil) var _ bridgev2.LoginProcessWithOverride = (*LineEmailLogin)(nil) func (ll *LineEmailLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := ll.loginCooldownError(); err != nil { + return nil, err + } return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeUserInput, StepID: "dev.highest.matrix.line.enter_creds", @@ -274,11 +279,14 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg } } + if err := ll.loginCooldownError(); err != nil { + return nil, err + } override.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { ll.logLoginFailure(err, "reconnect") + ll.noteLoginRateLimit(err) reason := loginErrorReason(err) if reason == "" { reason = genericLoginFailureReason @@ -303,9 +311,13 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] return ll.loginErrorStep("Email and password are required"), nil } + if err := ll.loginCooldownError(); err != nil { + return nil, err + } res, err := ll.loginCredentials(ctx, "") if err != nil { ll.logLoginFailure(err, "credentials") + ll.noteLoginRateLimit(err) reason := loginErrorReason(err) if reason == "" { reason = genericLoginFailureReason @@ -435,16 +447,6 @@ func loginErrorSummary(err error, details loginErrorDetails) string { return "" } -func loginLogField(value string) string { - value = strings.TrimSpace(value) - const maxRunes = 512 - runes := []rune(value) - if len(runes) > maxRunes { - return string(runes[:maxRunes]) + "…" - } - return value -} - func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { if err == nil || ll.User == nil { return @@ -464,19 +466,31 @@ func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { event.Int("line_response_code", details.ResponseCode) } if details.ResponseMessage != "" { - event.Str("line_response_message", loginLogField(details.ResponseMessage)) + event.Str("line_response_message", "redacted") } if details.ErrorName != "" { - event.Str("line_error_name", loginLogField(details.ErrorName)) + switch details.ErrorName { + case "TalkException", "LoginException", "SecondaryQrCodeException", "SecondaryQrCodeLoginException": + event.Str("line_error_name", details.ErrorName) + default: + event.Str("line_error_name", "other") + } } if details.HasErrorCode { event.Int("line_error_code", details.ErrorCode) } if details.ErrorMessage != "" { - event.Str("line_error_message", loginLogField(details.ErrorMessage)) + event.Str("line_error_message", "redacted") } if details.ErrorReason != "" { - event.Str("line_error_reason", loginLogField(details.ErrorReason)) + event.Str("line_error_reason", line.SanitizeProviderAlert(details.ErrorReason)) + } + var authErr *line.AuthError + if errors.As(wrapLineLoginError(err), &authErr) { + if authErr.ProviderMessage != "" { + event.Str("provider_message", authErr.ProviderMessage) + } + event.Str("provider_message_status", authErr.ProviderMessageStatus) } event.Msg("LINE login attempt failed") } @@ -526,7 +540,11 @@ func (ll *LineEmailLogin) logQRFailure(err error, flow string) { if ae.ProviderReason != "" { event.Str("provider_reason", ae.ProviderReason) } - event.Str("provider_alert_status", string(ae.ProviderAlertStatus)). + if ae.ProviderMessage != "" { + event.Str("provider_message", ae.ProviderMessage) + } + event.Str("provider_message_status", ae.ProviderMessageStatus). + Str("provider_alert_status", string(ae.ProviderAlertStatus)). Bool("retry_recommended", ae.RetryRecommended). Bool("temporary", ae.Temporary). Bool("user_action_required", ae.UserActionRequired). @@ -539,11 +557,7 @@ func (ll *LineEmailLogin) logQRFailure(err error, flow string) { func loginErrorReason(err error) string { details := parseLoginErrorDetails(err) - reason := details.ErrorReason - if reason == "" { - reason = details.ErrorMessage - } - return safeEmailLoginReason(reason) + return safeEmailLoginReason(providerLoginMessage(details)) } func isBlockedUserLoginError(message string) bool { @@ -613,7 +627,8 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) return nil, ErrLoginVerificationFailed case err := <-errCh: ll.logLoginFailure(err, "verification_poll") - return nil, wrapLineLoginError(err) + ll.noteLoginRateLimit(err) + return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) case <-done: return nil, context.Canceled case <-ctx.Done(): @@ -623,10 +638,14 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) } if awaitingPIN { + if err := ll.loginCooldownError(); err != nil { + return nil, err + } res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { ll.logLoginFailure(err, "pin_continuation") - return nil, wrapLineLoginError(err) + ll.noteLoginRateLimit(err) + return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) } return ll.handleLoginResponse(ctx, res) } @@ -777,7 +796,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult client.LoginAttempt = res.Attempt profile, err := client.GetProfileContext(ctx) if err != nil { - return nil, wrapLineLoginError(fmt.Errorf("failed to verify token: %w", err)) + return nil, fmt.Errorf("failed to verify token: %w", err) } displayName := profile.DisplayName diff --git a/pkg/connector/login_cooldown.go b/pkg/connector/login_cooldown.go index 5e0ef2ee..020faf58 100644 --- a/pkg/connector/login_cooldown.go +++ b/pkg/connector/login_cooldown.go @@ -51,8 +51,15 @@ func (c *loginCooldown) block(userID string, until time.Time) { } c.mu.Lock() defer c.mu.Unlock() + now := time.Now() if c.until == nil { c.until = make(map[string]time.Time) + } else { + for account, expiry := range c.until { + if !expiry.After(now) { + delete(c.until, account) + } + } } if current, ok := c.until[userID]; !ok || until.After(current) { c.until[userID] = until @@ -147,6 +154,11 @@ func (ll *LineEmailLogin) noteLoginRateLimit(err error) { return } now := time.Now() + var authErr *line.AuthError + if errors.As(err, &authErr) && authErr.RetryAfter != nil { + ll.cooldown.block(string(ll.User.MXID), now.Add(*authErr.RetryAfter)) + return + } switch classifyLoginRateLimit(err) { case loginRateLimitLocked: ll.cooldown.block(string(ll.User.MXID), now.Add(loginRateLimitCooldown)) diff --git a/pkg/connector/login_cooldown_test.go b/pkg/connector/login_cooldown_test.go index a8690bae..93c4f2a1 100644 --- a/pkg/connector/login_cooldown_test.go +++ b/pkg/connector/login_cooldown_test.go @@ -57,6 +57,42 @@ func runQRLoginToRejection(t *testing.T, body string, status int) (*LineConnecto return lc.UserLogin.Bridge.Network.(*LineConnector), lc.UserLogin.User, err } +func TestQRStartupRateLimitMapsAndStartsCooldown(t *testing.T) { + for _, method := range []string{"createSession", "createQrCode"} { + t.Run(method, func(t *testing.T) { + calls := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + gotMethod := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + if method == "createQrCode" && gotMethod == "createSession" { + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + } + calls++ + if gotMethod != method { + t.Fatalf("unexpected QR RPC %s, wanted %s", gotMethod, method) + } + resp := resolutionResponse(`{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`) + resp.StatusCode = http.StatusTooManyRequests + return resp, nil + }) + connector := lc.UserLogin.Bridge.Network.(*LineConnector) + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User, cooldown: &connector.loginCooldown}} + defer lq.Cancel() + _, err := lq.Start(context.Background()) + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("startup failure was not mapped to a rate limit: %v", err) + } + if connector.loginCooldown.remaining(string(lc.UserLogin.User.MXID)) <= 0 { + t.Fatal("startup rate limit did not activate account cooldown") + } + _, again := lq.Start(context.Background()) + if again == nil || calls != 1 { + t.Fatalf("startup cooldown did not prevent another provider call: calls=%d err=%v", calls, again) + } + }) + } +} + func TestQRRateLimitIsReportedAndBlocksImmediateRetry(t *testing.T) { for _, tc := range []struct { name, body, wantMessage string @@ -215,6 +251,17 @@ func TestLoginCooldownExpires(t *testing.T) { } } +func TestLoginCooldownUsesRetryAfter(t *testing.T) { + var cooldown loginCooldown + user := &bridgev2.User{User: &database.User{MXID: "@user:example.com"}} + ll := &LineEmailLogin{User: user, cooldown: &cooldown} + delay := 37 * time.Second + ll.noteLoginRateLimit(&line.AuthError{Category: line.AuthErrorTooManyAttempts, RetryAfter: &delay}) + if remaining := cooldown.remaining(string(user.MXID)); remaining < delay-time.Second || remaining > delay { + t.Fatalf("cooldown=%s, want provider Retry-After %s", remaining, delay) + } +} + func TestLoginRateLimitClassification(t *testing.T) { for _, tc := range []struct { name string diff --git a/pkg/connector/login_error_logging_test.go b/pkg/connector/login_error_logging_test.go index 90b3c8da..d34e9e65 100644 --- a/pkg/connector/login_error_logging_test.go +++ b/pkg/connector/login_error_logging_test.go @@ -57,11 +57,11 @@ func TestSubmitUserInputLogsStructuredLoginErrorWithoutCredentials(t *testing.T) "error_summary": "API error 400", "http_status": float64(400), "line_response_code": float64(10051), - "line_response_message": "RESPONSE_ERROR", + "line_response_message": "redacted", "line_error_name": "TalkException", "line_error_code": float64(35), - "line_error_message": "Blocked user", - "line_error_reason": "blocked user", + "line_error_message": "redacted", + "line_error_reason": "blocked_user", } for key, want := range wantFields { if got := event[key]; got != want { @@ -102,12 +102,12 @@ func TestLoginErrorReasonDoesNotLeakUnknownProviderText(t *testing.T) { }{ {"blocked user", loginTooManyAttemptsReason}, {"Too many login attempts", loginTooManyAttemptsReason}, - {"Verification is temporarily unavailable", line.UserMessageForCategory(line.AuthErrorVerificationTemporarilyUnavailable)}, + {"Verification is temporarily unavailable", "Verification is temporarily unavailable"}, {"Account ID or password is invalid", "Account ID or password is invalid"}, {"Authentication failed", "Authentication failed"}, {"", ""}, {"response-token secret", ""}, - {"password: hunter2", ""}, + {"password: hunter2", "password=[redacted]"}, } { t.Run(tc.reason, func(t *testing.T) { if got := safeEmailLoginReason(tc.reason); got != tc.want { diff --git a/pkg/connector/login_qr.go b/pkg/connector/login_qr.go index af7d0142..bed713cc 100644 --- a/pkg/connector/login_qr.go +++ b/pkg/connector/login_qr.go @@ -53,6 +53,9 @@ func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) { if err := ctx.Err(); err != nil { return nil, err } + if err := lq.login.loginCooldownError(); err != nil { + return nil, err + } ll := lq.login ll.mu.Lock() if ll.canceled || ll.attemptCtx != nil { @@ -66,7 +69,6 @@ func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) { defer stop() client := newLineAPIClient("") session, qr, err := client.StartQRLogin(processCtx) - err = wrapLineLoginError(err) ll.mu.Lock() ll.attempt = client.LoginAttempt canceled := ll.canceled || ctx.Err() != nil || processCtx.Err() != nil @@ -81,6 +83,11 @@ func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) { err = context.Canceled } lq.Cancel() + if err != nil { + ll.noteLoginRateLimit(err) + ll.logQRFailure(err, "qr_create") + return nil, wrapQRRPCError(err) + } return nil, err } lq.client, lq.session, lq.qr = client, session, qr @@ -135,11 +142,13 @@ func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { select { case err := <-lq.poll: if err != nil { + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_verify") lq.Cancel() if line.IsQRLoginPollExpired(err) { return nil, fmt.Errorf("%w: %w", ErrLoginVerificationFailed, err) } - return nil, err + return nil, wrapQRRPCError(err) } case <-lq.login.attemptCtx.Done(): lq.Cancel() @@ -151,16 +160,20 @@ func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { if !lq.pin { verified, err := lq.client.VerifyQRCertificate(ctx, lq.session, lq.login.Certificate) if err != nil { + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_certificate") lq.Cancel() - return nil, wrapLineLoginError(err) + return nil, wrapQRRPCError(err) } if verified { return lq.finish(ctx) } pin, err := lq.client.CreatePinCode(ctx, lq.session) if err != nil { + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_pin") lq.Cancel() - return nil, wrapLineLoginError(err) + return nil, wrapQRRPCError(err) } lq.pin = true lq.startPoll(lq.client.CheckPinCodeVerifiedContext, false) @@ -177,7 +190,9 @@ func (lq *LineQRLogin) finish(ctx context.Context) (*bridgev2.LoginStep, error) defer lq.Cancel() res, err := lq.client.QRCodeLoginV2(ctx, lq.session) if err != nil { - return nil, wrapLineLoginError(err) + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_token_issue") + return nil, wrapQRRPCError(err) } return lq.login.finishLogin(ctx, res) } diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index f5ec1e2f..ce4ae6c1 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -33,7 +33,12 @@ var ( ErrLoginTooManyAttempts = bridgev2.RespError{ ErrCode: "DEV.HIGHEST.LINE.TOO_MANY_ATTEMPTS", Err: loginTooManyAttemptsReason, - StatusCode: http.StatusBadRequest, + StatusCode: http.StatusTooManyRequests, + } + ErrLoginRateLimited = bridgev2.RespError{ + ErrCode: "DEV.HIGHEST.LINE.RATE_LIMITED", + Err: "LINE is limiting how often this account can sign in. Please try again later.", + StatusCode: http.StatusTooManyRequests, } ErrLoginRejected = bridgev2.RespError{ ErrCode: "DEV.HIGHEST.LINE.LOGIN_REJECTED", @@ -65,10 +70,10 @@ func safeEmailLoginReason(reason string) string { switch strings.ToLower(strings.TrimSpace(reason)) { case "blocked user", "too many login attempts": return loginTooManyAttemptsReason - case "verification is temporarily unavailable": - return line.UserMessageForCategory(line.AuthErrorVerificationTemporarilyUnavailable) - case "authentication failed", "account id or password is invalid": - return reason + } + safe, status := line.SanitizeProviderMessage(reason) + if status == "recognized" || status == "partially_redacted" || status == "safe_unrecognized" { + return safe } return "" } @@ -76,15 +81,20 @@ func safeEmailLoginReason(reason string) string { // wrapLineLoginError translates a LINE error into one the client can act on, // keeping the original in the chain with %w so logs are unaffected. func wrapLineLoginError(err error) error { + return wrapLineLoginErrorAt(err, line.AuthStageEmailLogin) +} + +func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { if err == nil { return nil } details := parseLoginErrorDetails(err) - reason := details.ErrorReason - if reason == "" { - reason = details.ErrorMessage - } + reason := providerLoginMessage(details) safeReason := safeEmailLoginReason(reason) + providerMessage, providerMessageStatus := line.SanitizeProviderMessage(reason) + if safeReason == "" { + safeReason = genericLoginFailureReason + } cat := classifyEmailError(details, reason) mapped := ErrLoginUnknown @@ -96,7 +106,7 @@ func wrapLineLoginError(err error) error { case cat == line.AuthErrorRetryLater: mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) case cat == line.AuthErrorVerificationTemporarilyUnavailable: - mapped = ErrLoginRejected.WithMessage("%s", line.UserMessageForCategory(line.AuthErrorVerificationTemporarilyUnavailable)) + mapped = ErrLoginRejected.WithMessage("%s", safeReason) case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: mapped = ErrLoginRejected case safeReason != "": @@ -104,42 +114,48 @@ func wrapLineLoginError(err error) error { } ae := &line.AuthError{ - Flow: "email", - Stage: line.AuthStageEmailLogin, - HTTPStatus: details.HTTPStatus, - ResponseCode: intPtr(details.ResponseCode), - ExceptionType: details.ErrorName, - ExceptionCode: intPtr(details.ErrorCode), - Category: cat, - ProviderReason: line.SanitizeProviderAlert(reason), - ProviderAlertStatus: line.SafeAlertStatus(details.ErrorReason != ""), - UserMessage: mapped.Err, - RetryRecommended: isRetryableCategory(cat), - Temporary: isTemporaryCategory(cat), - UserActionRequired: true, - BridgeFailure: false, - CooldownTriggered: cat == line.AuthErrorTooManyAttempts || cat == line.AuthErrorRetryLater || cat == line.AuthErrorVerificationTemporarilyUnavailable, - Err: err, + Flow: "email", + Stage: stage, + HTTPStatus: details.HTTPStatus, + ResponseCode: intPtr(details.ResponseCode), + ExceptionType: details.ErrorName, + ExceptionCode: intPtr(details.ErrorCode), + Category: cat, + ProviderReason: line.SanitizeProviderAlert(reason), + ProviderAlertStatus: providerMessageStatus, + ProviderMessage: providerMessage, + ProviderMessageStatus: providerMessageStatus, + UserMessage: mapped.Err, + RetryRecommended: isRetryableCategory(cat), + Temporary: isTemporaryCategory(cat), + UserActionRequired: true, + BridgeFailure: false, + CooldownTriggered: cat == line.AuthErrorTooManyAttempts || cat == line.AuthErrorRetryLater || cat == line.AuthErrorVerificationTemporarilyUnavailable, + Err: err, + } + if providerMessageStatus == "recognized" || providerMessageStatus == "partially_redacted" || providerMessageStatus == "safe_unrecognized" { + ae.UserMessage = providerMessage } return fmt.Errorf("%w: %w", ae, mapped) } // classifyEmailError classifies an email/login error into an AuthErrorCategory. func classifyEmailError(details loginErrorDetails, reason string) line.AuthErrorCategory { + safeCategory := line.SanitizeProviderAlert(reason) switch { - case isBlockedUserLoginError(reason): + case safeCategory == "blocked_user": return line.AuthErrorTooManyAttempts - case isLoginRateLimitText(reason): + case safeCategory == "too_many_login_attempts": return line.AuthErrorTooManyAttempts + case strings.EqualFold(strings.TrimSpace(reason), "retry later"): + return line.AuthErrorRetryLater case details.HTTPStatus == http.StatusTooManyRequests: return line.AuthErrorTooManyAttempts - case strings.EqualFold(strings.TrimSpace(reason), "blocked user"): - return line.AuthErrorTooManyAttempts case strings.EqualFold(strings.TrimSpace(reason), "authentication failed"): return line.AuthErrorInvalidCredentials case strings.EqualFold(strings.TrimSpace(reason), "account id or password is invalid"): return line.AuthErrorInvalidCredentials - case strings.EqualFold(strings.TrimSpace(reason), "verification is temporarily unavailable"): + case safeCategory == "verification_temporarily_unavailable": return line.AuthErrorVerificationTemporarilyUnavailable case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: return line.AuthErrorInvalidCredentials @@ -148,6 +164,14 @@ func classifyEmailError(details loginErrorDetails, reason string) line.AuthError } } +func providerLoginMessage(details loginErrorDetails) string { + reason := details.ErrorReason + if _, status := line.SanitizeProviderMessage(reason); status == "absent" || status == "suppressed" { + return details.ErrorMessage + } + return reason +} + // isRetryableCategory returns true if the category suggests a retry may succeed. func isRetryableCategory(cat line.AuthErrorCategory) bool { switch cat { @@ -200,8 +224,8 @@ func wrapQRRPCError(err error) error { if err == nil { return nil } - var respErr bridgev2.RespError - if errors.As(err, &respErr) { + detail, ok := line.QRLoginFailureDetail(err) + if !ok { return err } @@ -225,21 +249,13 @@ func wrapQRRPCError(err error) error { } var mapped bridgev2.RespError - switch statusCode := line.HTTPStatusCode(err); { - case errors.Is(err, errLineSessionInvalidated), errors.Is(err, errLineClientSuperseded), line.IsAuthError(err): - mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("Not logged in to LINE. Reconnect and try again.")) - case line.IsNotAMemberError(err): - mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("You are not a member of this LINE chat")) - case line.IsTalkExceptionNotFound(err): - mapped = bridgev2.RespError(mautrix.MNotFound.WithMessage("Not found on LINE")) - case statusCode == http.StatusTooManyRequests: - mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("LINE is rate limiting requests. Please wait a moment and try again.").WithStatus(http.StatusBadRequest)) - case errors.Is(err, context.DeadlineExceeded): - mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Timed out waiting for LINE")) - case line.IsTalkException(err), statusCode >= 400 && statusCode < 500: - mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("LINE rejected the request")) + switch classifyLoginRateLimit(err) { + case loginRateLimitLocked: + mapped = ErrLoginTooManyAttempts + case loginRateLimitRetryLater: + mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) default: - mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Request to LINE failed")) + mapped = ErrLoginRejected.WithMessage("%s", qrLoginFailureMessage(detail)) } return fmt.Errorf("%w: %w", mapped, err) } diff --git a/pkg/connector/qr_failure_test.go b/pkg/connector/qr_failure_test.go index a233e0ed..163c6e4a 100644 --- a/pkg/connector/qr_failure_test.go +++ b/pkg/connector/qr_failure_test.go @@ -154,7 +154,7 @@ func TestQRVerificationTemporarilyUnavailable(t *testing.T) { case "checkPinCodeVerified": return resolutionResponse(`{"code":0,"data":{}}`), nil case "qrCodeLoginV2": - resp := resolutionResponse(`{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"Verification is temporarily unavailable"}}`) + resp := resolutionResponse("{\"code\":10051,\"data\":{\"name\":\"SecondaryQrCodeException\",\"code\":100,\"alertMessage\":\"Verification is temporarily unavailable.\\nPlease try again later.\"}}") resp.StatusCode = http.StatusBadRequest return resp, nil default: @@ -180,6 +180,12 @@ func TestQRVerificationTemporarilyUnavailable(t *testing.T) { if !errors.As(err, &respErr) { t.Fatalf("QR rejection is not a user-facing error: %v", err) } + if !strings.Contains(respErr.Err, "Verification is temporarily unavailable.\nPlease try again later.") { + t.Fatalf("meaningful LINE verification message was lost: %q", respErr.Err) + } + if strings.Contains(strings.ToLower(respErr.Err), "qr sign-in has expired") { + t.Fatalf("verification restriction was misclassified as QR expiration: %q", respErr.Err) + } var event map[string]any if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &event); err != nil { @@ -200,9 +206,4 @@ func TestQRVerificationTemporarilyUnavailable(t *testing.T) { if event["temporary"] != true { t.Fatalf("temporary = %v, want true", event["temporary"]) } - for _, secret := range []string{"Verification is temporarily unavailable"} { - if strings.Contains(respErr.Err, secret) || strings.Contains(err.Error(), secret) { - t.Fatalf("provider text leaked into the user-facing error: %v", err) - } - } } diff --git a/pkg/line/auth_error.go b/pkg/line/auth_error.go index 7252dac0..52a7a5a6 100644 --- a/pkg/line/auth_error.go +++ b/pkg/line/auth_error.go @@ -3,8 +3,10 @@ package line import ( "errors" "fmt" + "regexp" "strings" "time" + "unicode" ) // AuthErrorCategory is a normalized classification of an authentication failure. @@ -85,6 +87,8 @@ const ( AuthStageQRTokenIssue // AuthStageEmailLogin is the email/password login stage. AuthStageEmailLogin + // AuthStageEmailVerification is PIN/email challenge completion after credentials were accepted. + AuthStageEmailVerification // AuthStageSessionEstablish is the session establishment stage. AuthStageSessionEstablish ) @@ -100,6 +104,8 @@ func (s AuthErrorStage) String() string { return "qr_token_issue" case AuthStageEmailLogin: return "email_login" + case AuthStageEmailVerification: + return "email_verification" case AuthStageSessionEstablish: return "session_establish" default: @@ -130,6 +136,10 @@ type AuthError struct { ProviderReason string // ProviderAlertStatus describes whether the provider alert was recognized. ProviderAlertStatus string + // ProviderMessage is safe provider wording for logs and user-facing copy. + ProviderMessage string + // ProviderMessageStatus distinguishes recognized, sanitized, redacted, and suppressed text. + ProviderMessageStatus string // UserMessage is the concise user-facing explanation. UserMessage string // RetryRecommended indicates whether retrying is appropriate. @@ -163,11 +173,64 @@ func (e *AuthError) Unwrap() error { // SafeAlertStatus returns a safe provider alert status string. Never returns // raw provider text. -func SafeAlertStatus(recognized bool) string { - if recognized { - return "recognized" +var ( + providerCredentialPattern = regexp.MustCompile(`(?i)\b(access[_ -]?token|refresh[_ -]?token|id[_ -]?token|password|passwd|authorization|cookie|authsessionid|session[_ -]?id|qr[_ -]?(token|secret)|credential|secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;.!?]+)([.!?]?)`) + providerEmailPattern = regexp.MustCompile(`(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b`) + providerJWTpattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b`) + providerOpaqueMIDPattern = regexp.MustCompile(`\bU[A-Za-z0-9_-]{43}\b`) +) + +// SanitizeProviderMessage keeps useful provider wording while removing common +// credentials and private identifiers. Unknown text is presented only when it +// is short, printable, and not structured or credential-like. +func SanitizeProviderMessage(message string) (safe, status string) { + if strings.TrimSpace(message) == "" { + return "", "absent" + } + for _, r := range message { + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' || unicode.Is(unicode.Cf, r) { + return "", "suppressed" + } + } + message = strings.ReplaceAll(strings.ReplaceAll(message, "\r\n", "\n"), "\r", "\n") + message = strings.ReplaceAll(message, "\t", " ") + message = strings.TrimSpace(message) + if len([]rune(message)) > 512 { + return "", "suppressed" + } + message = providerCredentialPattern.ReplaceAllString(message, "$1=[redacted]$4") + message = providerEmailPattern.ReplaceAllString(message, "[redacted email]") + message = providerJWTpattern.ReplaceAllString(message, "[redacted token]") + message = providerOpaqueMIDPattern.ReplaceAllString(message, "[redacted LINE ID]") + if strings.ContainsAny(message, "{}") || strings.Contains(strings.ToLower(message), "bearer ") || strings.Contains(message, "https://") || strings.Contains(message, "http://") { + return "", "suppressed" + } + if (strings.Contains(strings.ToLower(message), "token ") || strings.Contains(strings.ToLower(message), "password ")) && !strings.Contains(message, "[redacted") && !isRecognizedProviderMessage(message) { + return "", "suppressed" + } + if strings.Contains(message, "[redacted") { + return message, "partially_redacted" + } + if isRecognizedProviderMessage(message) { + return message, "recognized" } - return "unrecognized" + if len([]rune(message)) < 5 { + return "", "suppressed" + } + if !strings.ContainsAny(message, " \n") && !strings.Contains(message, "[redacted") { + return "", "suppressed" + } + return message, "safe_unrecognized" +} + +func isRecognizedProviderMessage(message string) bool { + lower := strings.ToLower(strings.Join(strings.Fields(message), " ")) + for _, phrase := range []string{"verification is temporarily unavailable", "too many login attempts", "retry later", "blocked user", "authentication failed", "account id or password is invalid", "qr code has expired", "qr login is not allowed"} { + if strings.Contains(lower, phrase) { + return true + } + } + return false } // SanitizeProviderAlert maps a raw LINE provider alert to a safe category @@ -179,9 +242,9 @@ func SanitizeProviderAlert(alert string) string { } lower := strings.ToLower(trimmed) switch { - case strings.EqualFold(lower, "verification is temporarily unavailable"): + case strings.Contains(lower, "verification is temporarily unavailable"): return "verification_temporarily_unavailable" - case strings.EqualFold(lower, "too many login attempts"): + case strings.Contains(lower, "too many login attempts"): return "too_many_login_attempts" case strings.EqualFold(lower, "blocked user"): return "blocked_user" diff --git a/pkg/line/auth_error_test.go b/pkg/line/auth_error_test.go new file mode 100644 index 00000000..5cdbd546 --- /dev/null +++ b/pkg/line/auth_error_test.go @@ -0,0 +1,25 @@ +package line + +import "testing" + +func TestSanitizeProviderMessage(t *testing.T) { + for _, tc := range []struct { + name, input, want, status string + }{ + {"verified verification alert", "Verification is temporarily unavailable.\nPlease try again later.", "Verification is temporarily unavailable.\nPlease try again later.", "recognized"}, + {"token field redacted", "Login failed; access_token=private-value. Please try later.", "Login failed; access_token=[redacted]. Please try later.", "partially_redacted"}, + {"email redacted", "Account for person@example.org is locked.", "Account for [redacted email] is locked.", "partially_redacted"}, + {"bounded safe provider text", "LINE could not complete this sign-in. Try again later.", "LINE could not complete this sign-in. Try again later.", "safe_unrecognized"}, + {"structured response suppressed", `{"token":"private"}`, "", "suppressed"}, + {"control sequence suppressed", "safe text\x1b[31mprivate", "", "suppressed"}, + {"oversized message suppressed", string(make([]byte, 600)), "", "suppressed"}, + {"short unknown suppressed", "no", "", "suppressed"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, status := SanitizeProviderMessage(tc.input) + if got != tc.want || status != tc.status { + t.Fatalf("SanitizeProviderMessage() = %q, %q; want %q, %q", got, status, tc.want, tc.status) + } + }) + } +} diff --git a/pkg/line/qr.go b/pkg/line/qr.go index 1c7bf406..c1543f34 100644 --- a/pkg/line/qr.go +++ b/pkg/line/qr.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" gen "github.com/beeper/line/pkg" @@ -89,6 +90,12 @@ func (c *Client) VerifyQRCertificate(ctx context.Context, session, certificate s err := c.callQRRPC(ctx, "verifyCertificate", "", 0, map[string]string{"authSessionId": session, "certificate": certificate}, nil) var response *qrRPCError if errors.As(err, &response) && response.httpStatus == http.StatusBadRequest { + if authErr := response.AuthError(); authErr != nil { + switch authErr.Category { + case AuthErrorTooManyAttempts, AuthErrorRetryLater, AuthErrorVerificationTemporarilyUnavailable, AuthErrorServiceUnavailable: + return false, err + } + } return false, nil } return err == nil, err @@ -104,7 +111,9 @@ type qrRPCError struct { exceptionLabel string reason string alert string + alertStatus string authError *AuthError + retryAfter time.Duration } // QRRPCError is the exported alias for qrRPCError, allowing callers to @@ -152,23 +161,42 @@ func (e *qrRPCError) Error() string { // fields captured before sanitization. It never exposes raw provider text // through user-facing fields. func (e *qrRPCError) buildAuthError(rawName string, rawCode *int, rawReason, rawAlert string) *AuthError { + stage := AuthStageQRTokenIssue + switch e.method { + case "createSession", "createQrCode": + stage = AuthStageQRCreate + case "verifyCertificate", "createPinCode", "checkQrCodeVerified", "checkPinCodeVerified": + stage = AuthStageQRVerify + } + providerMessage := rawAlert + if strings.TrimSpace(providerMessage) == "" { + providerMessage = rawReason + } + safeProviderMessage, providerMessageStatus := SanitizeProviderMessage(providerMessage) + _, providerAlertStatus := SanitizeProviderMessage(rawAlert) + if e.alertStatus != "" { + providerAlertStatus = e.alertStatus + } ae := &AuthError{ - Flow: "qr", - Stage: AuthStageQRTokenIssue, - HTTPStatus: e.httpStatus, - ResponseCode: e.code, - ExceptionType: e.exceptionType, - ExceptionCode: e.exceptionCode, - Category: AuthErrorUnknown, - ProviderReason: SanitizeProviderAlert(rawReason), - ProviderAlertStatus: SafeAlertStatus(rawAlert != ""), - UserMessage: UserMessageForCategory(AuthErrorUnknown), - RetryRecommended: false, - Temporary: false, - UserActionRequired: true, - BridgeFailure: false, - CooldownTriggered: false, - Err: e, + Flow: "qr", + Stage: stage, + HTTPStatus: e.httpStatus, + ResponseCode: e.code, + ExceptionType: e.exceptionType, + ExceptionCode: e.exceptionCode, + Category: AuthErrorUnknown, + ProviderReason: SanitizeProviderAlert(rawReason), + ProviderAlertStatus: providerAlertStatus, + ProviderMessage: safeProviderMessage, + ProviderMessageStatus: providerMessageStatus, + UserMessage: UserMessageForCategory(AuthErrorUnknown), + RetryRecommended: false, + Temporary: false, + UserActionRequired: true, + BridgeFailure: false, + CooldownTriggered: false, + RetryAfter: durationPtr(e.retryAfter), + Err: e, } if ae.ProviderReason == "absent" && rawAlert != "" { ae.ProviderReason = SanitizeProviderAlert(rawAlert) @@ -206,9 +234,36 @@ func (e *qrRPCError) buildAuthError(rawName string, rawCode *int, rawReason, raw applyCategoryDefaults(&ae.RetryRecommended, &ae.Temporary, &ae.CooldownTriggered, ae.Category) ae.UserMessage = userMessageForStageAndCategory(ae.Stage, ae.Category, rawAlert) + if providerMessageStatus == "recognized" || providerMessageStatus == "partially_redacted" || providerMessageStatus == "safe_unrecognized" { + ae.UserMessage = safeProviderMessage + } return ae } +func durationPtr(duration time.Duration) *time.Duration { + if duration <= 0 { + return nil + } + return &duration +} + +func parseRetryAfter(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + if seconds > int64((24*time.Hour)/time.Second) { + return 24 * time.Hour + } + if seconds > 0 { + return time.Duration(seconds) * time.Second + } + return 0 + } + if at, err := http.ParseTime(value); err == nil && at.After(now) { + return min(at.Sub(now), 24*time.Hour) + } + return 0 +} + // applyCategoryDefaults sets RetryRecommended, Temporary, and CooldownTriggered // from the category when the classifier did not already set them. func applyCategoryDefaults(retry, temporary, cooldown *bool, cat AuthErrorCategory) { @@ -458,7 +513,7 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime } decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&wrapper) if resp.StatusCode != http.StatusOK || (wrapper.Code != nil && *wrapper.Code != 0) { - response := &qrRPCError{method: method, httpStatus: resp.StatusCode, code: wrapper.Code} + response := &qrRPCError{method: method, httpStatus: resp.StatusCode, code: wrapper.Code, retryAfter: parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())} var details struct { StatusCode *int `json:"statusCode"` Code *int `json:"code"` @@ -478,13 +533,10 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime default: response.exceptionType = "other" } - response.reason = safeLoginPollingReason(details.Reason) - if details.Reason != "" && response.reason == "" { - response.reason = "redacted" - } - response.alert = safeLoginPollingReason(details.Alert) - if details.Alert != "" && response.alert == "" { - response.alert = "redacted" + response.reason, _ = SanitizeProviderMessage(details.Reason) + response.alert, response.alertStatus = SanitizeProviderMessage(details.Alert) + if details.Alert == "" { + response.alertStatus = "" } if details.Name == "SecondaryQrCodeException" && details.Code != nil { response.exceptionLabel = secondaryQrCodeErrorLabels[*details.Code] diff --git a/pkg/line/qr_error_test.go b/pkg/line/qr_error_test.go index feab6b7e..34d6625e 100644 --- a/pkg/line/qr_error_test.go +++ b/pkg/line/qr_error_test.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" "testing" + "time" gen "github.com/beeper/line/pkg" ) @@ -18,9 +19,9 @@ func TestQRErrorDetailsAndRedaction(t *testing.T) { expired bool }{ {"nested rejection", `{"code":10051,"message":"PRIVATE","data":{"name":"TalkException","code":35,"reason":"blocked user","accessToken":"PRIVATE","authSessionId":"PRIVATE"}}`, "exception code 35, exception type TalkException, reason blocked user", 400, false}, - {"HTTP success API failure", `{"code":10051,"data":{"name":"SecondaryQrCodeLoginException","code":0,"reason":"PRIVATE"}}`, "exception code 0, exception type SecondaryQrCodeLoginException, reason redacted", 200, false}, - {"unknown exception", `{"code":10051,"data":{"name":"PRIVATE","code":99,"reason":"PRIVATE"}}`, "exception code 99, exception type other, reason redacted", 400, false}, - {"QR exception with private alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"PRIVATE"}}`, "exception code 100, exception type SecondaryQrCodeException, alert redacted", 400, false}, + {"HTTP success API failure", `{"code":10051,"data":{"name":"SecondaryQrCodeLoginException","code":0,"reason":"PRIVATE"}}`, "exception code 0, exception type SecondaryQrCodeLoginException", 200, false}, + {"unknown exception", `{"code":10051,"data":{"name":"PRIVATE","code":99,"reason":"PRIVATE"}}`, "exception code 99, exception type other", 400, false}, + {"QR exception with private alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"PRIVATE"}}`, "exception code 100, exception type SecondaryQrCodeException", 400, false}, {"QR exception with allowlisted alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5,"alertMessage":"Too many login attempts"}}`, "exception code 5, exception type SecondaryQrCodeException, alert Too many login attempts", 400, false}, {"poll expired", `{"code":10052,"data":{"statusCode":410}}`, "code 10052, status 410", 400, true}, {"missing detail", `{"code":10051,"data":{}}`, "QR qrCodeLoginV2 failed: HTTP 400, code 10051", 400, false}, @@ -67,6 +68,36 @@ func TestQRLoginFailureDetailIgnoresOtherErrors(t *testing.T) { } } +func TestParseRetryAfter(t *testing.T) { + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + if got := parseRetryAfter("17", now); got != 17*time.Second { + t.Fatalf("seconds Retry-After = %s", got) + } + if got := parseRetryAfter(now.Add(2*time.Minute).Format(http.TimeFormat), now); got != 2*time.Minute { + t.Fatalf("date Retry-After = %s", got) + } + if got := parseRetryAfter("invalid", now); got != 0 { + t.Fatalf("invalid Retry-After = %s", got) + } +} + +func TestQRAuthErrorStage(t *testing.T) { + for method, want := range map[string]AuthErrorStage{ + "createSession": AuthStageQRCreate, + "createQrCode": AuthStageQRCreate, + "verifyCertificate": AuthStageQRVerify, + "checkQrCodeVerified": AuthStageQRVerify, + "createPinCode": AuthStageQRVerify, + "checkPinCodeVerified": AuthStageQRVerify, + "qrCodeLoginV2": AuthStageQRTokenIssue, + } { + err := (&qrRPCError{method: method, httpStatus: http.StatusBadRequest}).buildAuthError("SecondaryQrCodeException", nil, "", "") + if err.Stage != want { + t.Errorf("method %s has stage %s, want %s", method, err.Stage, want) + } + } +} + func TestQRRPCErrorAuthError(t *testing.T) { for _, tc := range []struct { name string @@ -77,6 +108,8 @@ func TestQRRPCErrorAuthError(t *testing.T) { wantRetryRecommended bool wantProviderAlertStatus string wantProviderReason string + retryAfter string + wantRetryAfter time.Duration }{ { name: "HTTP 429", @@ -85,8 +118,10 @@ func TestQRRPCErrorAuthError(t *testing.T) { wantCategory: AuthErrorTooManyAttempts, wantCooldownTriggered: true, wantRetryRecommended: true, - wantProviderAlertStatus: "unrecognized", + wantProviderAlertStatus: "absent", wantProviderReason: "absent", + retryAfter: "17", + wantRetryAfter: 17 * time.Second, }, { name: "retry later", @@ -95,7 +130,7 @@ func TestQRRPCErrorAuthError(t *testing.T) { wantCategory: AuthErrorRetryLater, wantCooldownTriggered: true, wantRetryRecommended: true, - wantProviderAlertStatus: "unrecognized", + wantProviderAlertStatus: "absent", wantProviderReason: "absent", }, { @@ -115,7 +150,7 @@ func TestQRRPCErrorAuthError(t *testing.T) { wantCategory: AuthErrorQRLoginNotAllowed, wantCooldownTriggered: true, wantRetryRecommended: false, - wantProviderAlertStatus: "unrecognized", + wantProviderAlertStatus: "absent", wantProviderReason: "absent", }, } { @@ -129,7 +164,11 @@ func TestQRRPCErrorAuthError(t *testing.T) { client.LoginAttempt = &LoginAttempt{Runner: runner, Context: ctx, cancel: cancel} defer client.LoginAttempt.Close() client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: tc.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.body))}, nil + headers := make(http.Header) + if tc.retryAfter != "" { + headers.Set("Retry-After", tc.retryAfter) + } + return &http.Response{StatusCode: tc.status, Header: headers, Body: io.NopCloser(strings.NewReader(tc.body))}, nil }) _, err = client.QRCodeLoginV2(ctx, "session") cancel() @@ -157,6 +196,9 @@ func TestQRRPCErrorAuthError(t *testing.T) { if ae.ProviderReason != tc.wantProviderReason { t.Fatalf("providerReason = %q, want %q", ae.ProviderReason, tc.wantProviderReason) } + if ae.RetryAfter == nil && tc.wantRetryAfter > 0 || ae.RetryAfter != nil && *ae.RetryAfter != tc.wantRetryAfter { + t.Fatalf("retryAfter = %v, want %s", ae.RetryAfter, tc.wantRetryAfter) + } }) } } From bde3468350d1ec69d6f4680fcfd3efc4a24e6118 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:11:23 -0400 Subject: [PATCH 14/35] fix: validate LINE lookup identities without folding opaque IDs --- pkg/connector/resolve_identifier.go | 33 +++++++----- pkg/connector/resolve_identifier_test.go | 68 ++++++++++++++++++++++++ pkg/connector/userinfo.go | 42 ++++++++++++--- pkg/line/user_mid.go | 25 +++++++++ pkg/line/user_mid_test.go | 32 +++++++++++ 5 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 pkg/line/user_mid.go create mode 100644 pkg/line/user_mid_test.go diff --git a/pkg/connector/resolve_identifier.go b/pkg/connector/resolve_identifier.go index 76772dab..d5b10d83 100644 --- a/pkg/connector/resolve_identifier.go +++ b/pkg/connector/resolve_identifier.go @@ -11,15 +11,13 @@ import ( ) func isCanonicalUserMID(identifier string) bool { - if len(identifier) != 33 || identifier[0] != 'u' { - return false - } - for _, ch := range identifier[1:] { - if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f') { - return false - } - } - return true + normalized, valid := line.NormalizeUserMID(identifier) + return valid && normalized == identifier +} + +func normalizeUserMID(identifier string) string { + normalized, _ := line.NormalizeUserMID(identifier) + return normalized } // resolveContactIdentifier never matches display names or creates portals. @@ -29,7 +27,12 @@ func (lc *LineClient) resolveContactIdentifier(ctx context.Context, identifier s if err := lc.checkLineCall(ctx); err != nil { return nil, err } - identifier = strings.ToLower(strings.TrimSpace(identifier)) + identifier = strings.TrimSpace(identifier) + if normalized, valid := line.NormalizeUserMID(identifier); valid { + identifier = normalized + } else { + identifier = strings.ToLower(identifier) + } if identifier == "" || strings.ContainsFunc(identifier, unicode.IsSpace) || strings.ContainsFunc(identifier, unicode.IsControl) { return nil, fmt.Errorf("provide a LINE public ID or user MID, not a display name") } @@ -56,8 +59,13 @@ func (lc *LineClient) resolveContactIdentifier(ctx context.Context, identifier s }) err = lookupErr if contacts != nil { - if wrapper, found := contacts.Contacts[identifier]; found { - contact = &wrapper.Contact + for key, wrapper := range contacts.Contacts { + if normalizeUserMID(key) == identifier { + if contact != nil { + return nil, fmt.Errorf("LINE lookup returned ambiguous user MID entries") + } + contact = &wrapper.Contact + } } } if err == nil && contact == nil { @@ -84,6 +92,7 @@ func (lc *LineClient) resolveContactIdentifier(ctx context.Context, identifier s if contact == nil || contact.Mid == "" { return nil, nil } + contact.Mid = normalizeUserMID(contact.Mid) if !isCanonicalUserMID(contact.Mid) || isCanonicalUserMID(identifier) && contact.Mid != identifier { return nil, fmt.Errorf("LINE lookup returned an invalid or mismatched user MID") } diff --git a/pkg/connector/resolve_identifier_test.go b/pkg/connector/resolve_identifier_test.go index b653cf05..83889cba 100644 --- a/pkg/connector/resolve_identifier_test.go +++ b/pkg/connector/resolve_identifier_test.go @@ -193,6 +193,74 @@ func TestResolveIdentifierMIDLookup(t *testing.T) { } } +func TestResolveIdentifierPreservesOpaqueMIDCase(t *testing.T) { + opaque := "U" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg" + for _, tc := range []struct { + name, key, responseMID string + wantErr bool + }{ + {"exact opaque", opaque, opaque, false}, + {"different opaque case", "U" + strings.ToLower(opaque[1:]), opaque, true}, + } { + t.Run(tc.name, func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/getContactsV2"): + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) < 1 { + t.Fatalf("opaque MID request malformed: %v", err) + } + var request struct { + TargetUserMids []string `json:"targetUserMids"` + } + if err := json.Unmarshal(args[0], &request); err != nil || len(request.TargetUserMids) != 1 || request.TargetUserMids[0] != opaque { + t.Fatalf("opaque MID was changed in request: %q, %v", request, err) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + tc.key + `":{"contact":{"mid":"` + tc.responseMID + `","displayName":"Opaque"}}}}}`), nil + case strings.HasSuffix(req.URL.Path, "/getBuddyProfile"): + return resolutionResponse(`{"code":0,"data":{}}`), nil + default: + t.Fatalf("opaque MID used unexpected RPC: %s", req.URL.Path) + return nil, nil + } + }) + result, err := lc.ResolveIdentifier(context.Background(), opaque, true) + if tc.wantErr && result != nil || !tc.wantErr && (err != nil || result == nil) { + t.Fatalf("result=%+v err=%v", result, err) + } + if tc.wantErr { + if result != nil { + t.Fatalf("mismatched opaque identity accepted: %+v", result) + } + return + } + if result == nil || string(result.UserID) != opaque || string(result.Chat.PortalKey.ID) != opaque { + t.Fatalf("opaque identity changed: %+v", result) + } + }) + } +} + +func TestSearchUsersRejectsUnrequestedContactMID(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/findContactByUserid"): + return resolutionResponse(`{"code":0,"data":{}}`), nil + case strings.HasSuffix(req.URL.Path, "/getAllContactIds"): + return resolutionResponse(`{"code":0,"data":["` + resolutionMID + `"]}`), nil + case strings.HasSuffix(req.URL.Path, "/getContactsV2"): + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + resolutionOwnMID + `":{"contact":{"mid":"` + resolutionOwnMID + `","displayName":"spoofed"}}}}}`), nil + default: + t.Fatalf("unexpected search RPC: %s", req.URL.Path) + return nil, nil + } + }) + results, err := lc.SearchUsers(context.Background(), "alice") + if err == nil || len(results) != 0 { + t.Fatalf("unrequested contact accepted: results=%+v err=%v", results, err) + } +} + func TestResolveIdentifierFailuresDoNotPersistPortals(t *testing.T) { for _, tc := range []struct { name, query, response string diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 7d67cde5..88c23970 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -353,12 +353,14 @@ func (lc *LineClient) midToResolveIdentifier(ctx context.Context, mid string) *b func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { var results []*bridgev2.ResolveIdentifierResponse + var exactErr, validationErr error // Exact public ID and MID lookup is shared with start-chat. Only search // falls back to non-unique display-name matches. lowerQuery := strings.ToLower(strings.TrimSpace(query)) if lowerQuery != "" { contact, err := lc.resolveContactIdentifier(ctx, query) + exactErr = err if ctx.Err() != nil { return nil, ctx.Err() } @@ -377,8 +379,7 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev if ctx.Err() != nil { return nil, ctx.Err() } - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to get all contact IDs for search") - return results, nil + return nil, fmt.Errorf("LINE contact search failed: %w", err) } // Fetch contacts in batches to check display names @@ -388,16 +389,26 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev end = len(allMids) } batch := allMids[i:end] + requested := make(map[string]bool, len(batch)) + for _, mid := range batch { + requested[normalizeUserMID(mid)] = true + } contactsResp, err := client.GetContactsV2Context(ctx, batch) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } - continue + return nil, fmt.Errorf("LINE contact search failed: %w", err) } - for _, wrapper := range contactsResp.Contacts { + keyCounts := make(map[string]int, len(contactsResp.Contacts)) + for key := range contactsResp.Contacts { + keyCounts[normalizeUserMID(key)]++ + } + for key, wrapper := range contactsResp.Contacts { c := wrapper.Contact - if c.Mid == "" { + c.Mid = normalizeUserMID(c.Mid) + if !isCanonicalUserMID(c.Mid) || normalizeUserMID(key) != c.Mid || !requested[c.Mid] || keyCounts[c.Mid] != 1 { + validationErr = fmt.Errorf("LINE contact search returned invalid or ambiguous user MIDs") continue } name := strings.ToLower(c.EffectiveDisplayName()) @@ -409,6 +420,14 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev } } } + if len(results) == 0 { + if exactErr != nil { + return nil, fmt.Errorf("exact LINE lookup failed and no matching contacts were found: %w", exactErr) + } + if validationErr != nil { + return nil, validationErr + } + } return results, nil } @@ -430,12 +449,21 @@ func (lc *LineClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveId end = len(allMids) } batch := allMids[i:end] + requested := make(map[string]bool, len(batch)) + for _, mid := range batch { + requested[normalizeUserMID(mid)] = true + } contactsResp, err := client.GetContactsV2(batch) if err != nil { continue } - for mid, wrapper := range contactsResp.Contacts { - if wrapper.Contact.Mid == "" { + keyCounts := make(map[string]int, len(contactsResp.Contacts)) + for key := range contactsResp.Contacts { + keyCounts[normalizeUserMID(key)]++ + } + for key, wrapper := range contactsResp.Contacts { + mid := normalizeUserMID(wrapper.Contact.Mid) + if !isCanonicalUserMID(mid) || normalizeUserMID(key) != mid || !requested[mid] || keyCounts[mid] != 1 { continue } if r := lc.midToResolveIdentifier(ctx, mid); r != nil { diff --git a/pkg/line/user_mid.go b/pkg/line/user_mid.go new file mode 100644 index 00000000..1e5ea44d --- /dev/null +++ b/pkg/line/user_mid.go @@ -0,0 +1,25 @@ +package line + +import "strings" + +// NormalizeUserMID preserves case-sensitive Chrome gateway IDs and canonicalizes +// legacy hexadecimal MIDs, which are case-insensitive. +func NormalizeUserMID(mid string) (string, bool) { + if len(mid) == 44 && mid[0] == 'U' { + for _, ch := range mid[1:] { + if !(ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '_' || ch == '-') { + return mid, false + } + } + return mid, true + } + if len(mid) != 33 || mid[0] != 'u' && mid[0] != 'U' { + return mid, false + } + for _, ch := range mid[1:] { + if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F') { + return mid, false + } + } + return strings.ToLower(mid), true +} diff --git a/pkg/line/user_mid_test.go b/pkg/line/user_mid_test.go new file mode 100644 index 00000000..4e4d3421 --- /dev/null +++ b/pkg/line/user_mid_test.go @@ -0,0 +1,32 @@ +package line + +import ( + "strings" + "testing" +) + +func TestNormalizeUserMID(t *testing.T) { + opaque := "U" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg" + legacy := "u0123456789abcdef0123456789abcdef" + for _, tc := range []struct { + name, input, want string + valid bool + }{ + {"opaque", opaque, opaque, true}, + {"case-sensitive opaque", "U" + strings.ToLower(opaque[1:]), "U" + strings.ToLower(opaque[1:]), true}, + {"legacy", legacy, legacy, true}, + {"legacy uppercase", strings.ToUpper(legacy), legacy, true}, + {"lowercase opaque prefix", strings.ToLower(opaque), "", false}, + {"wrong kind", "C" + opaque[1:], "", false}, + {"short", opaque[:43], "", false}, + {"padding", opaque[:43] + "=", "", false}, + {"nonhex legacy", legacy[:32] + "g", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, valid := NormalizeUserMID(tc.input) + if valid != tc.valid || valid && got != tc.want || !valid && got != tc.input { + t.Fatalf("NormalizeUserMID(%q) = %q, %v", tc.input, got, valid) + } + }) + } +} From 971a8286099fb84c602f3e8902da30312351ed32 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 17:13:49 -0400 Subject: [PATCH 15/35] test: cover DM reopening and passwordless QR reconnects Verify public-ID reopening after leaving a Matrix room, rejection of mismatched MIDs and expired lookup requests, and mandatory fresh approval for passwordless QR sessions after token recovery is unavailable. --- pkg/connector/login_qr_test.go | 10 +++++ pkg/connector/resolve_identifier_test.go | 47 +++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pkg/connector/login_qr_test.go b/pkg/connector/login_qr_test.go index f35be5b5..ec146999 100644 --- a/pkg/connector/login_qr_test.go +++ b/pkg/connector/login_qr_test.go @@ -191,3 +191,13 @@ func TestQRStartPreservesDeadline(t *testing.T) { t.Fatalf("start after deadline = %+v, %v; want DeadlineExceeded", step, err) } } + +func TestQRPasswordlessReconnectNeedsApproval(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected passwordless authentication request: %s", req.URL.Path) + }) + lc.UserLogin.Bridge.Network.(*LineConnector).Config.QRLogin = true + if err := lc.tryLogin(context.Background()); !errors.Is(err, errLineQRLoginRequired) { + t.Fatalf("passwordless QR session must request fresh approval: %v", err) + } +} diff --git a/pkg/connector/resolve_identifier_test.go b/pkg/connector/resolve_identifier_test.go index 83889cba..5f550128 100644 --- a/pkg/connector/resolve_identifier_test.go +++ b/pkg/connector/resolve_identifier_test.go @@ -39,6 +39,8 @@ type resolutionMatrix struct { mu sync.Mutex created int failCreate bool + ownerLeft bool + deleted int } func (*resolutionMatrix) Init(*bridgev2.Bridge) {} @@ -53,8 +55,14 @@ func (*resolutionMatrix) GetCapabilities() *bridgev2.MatrixCapabilities { } func (*resolutionMatrix) ParseGhostMXID(id.UserID) (networkid.UserID, bool) { return "", false } func (*resolutionMatrix) GenerateDeterministicRoomID(networkid.PortalKey) id.RoomID { return "" } -func (*resolutionMatrix) GetMembers(context.Context, id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { - return map[id.UserID]*event.MemberEventContent{"@owner:example.com": {Membership: event.MembershipJoin}}, nil +func (m *resolutionMatrix) GetMembers(context.Context, id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + m.mu.Lock() + defer m.mu.Unlock() + membership := event.MembershipJoin + if m.ownerLeft { + membership = event.MembershipLeave + } + return map[id.UserID]*event.MemberEventContent{"@owner:example.com": {Membership: membership}}, nil } type resolutionIntent struct { @@ -85,6 +93,13 @@ func (i *resolutionIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreat return id.RoomID(fmt.Sprintf("!created%d:example.com", i.matrix.created)), nil } +func (i *resolutionIntent) DeleteRoom(context.Context, id.RoomID, bool) error { + i.matrix.mu.Lock() + defer i.matrix.mu.Unlock() + i.matrix.deleted++ + return nil +} + func newResolutionClient(t *testing.T, transport resolutionTransport) *LineClient { t.Helper() db, err := dbutil.NewWithDialect("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "bridge.db")), "sqlite3") @@ -317,6 +332,24 @@ func TestResolveIdentifierCancellation(t *testing.T) { assertPortalCount(t, lc, 0) } +func TestResolveIdentifierDeadlineAndMismatchedMID(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/getContactsV2") { + return nil, fmt.Errorf("unexpected endpoint: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + resolutionMID + `":{"contact":{"mid":"u22222222222222222222222222222222","displayName":"Wrong recipient"}}}}}`), nil + }) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + if resp, err := lc.ResolveIdentifier(ctx, resolutionMID, true); resp != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expired lookup: %+v %v", resp, err) + } + if resp, err := lc.ResolveIdentifier(context.Background(), resolutionMID, true); resp != nil || err == nil { + t.Fatalf("mismatched MID accepted: %+v %v", resp, err) + } + assertPortalCount(t, lc, 0) +} + func TestResolveIdentifierConcurrentCanonicalPortalReuse(t *testing.T) { lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { @@ -411,4 +444,14 @@ func TestStartChatCreatesReusesAndRetriesRoom(t *testing.T) { t.Fatalf("created %d rooms", matrix.created) } assertPortalCount(t, lc, 1) + oldRoomID := created.DMRoomID + matrix.ownerLeft = true + reopened, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "carol.id", true) + if err != nil || reopened.DMRoomID == oldRoomID || reopened.DMRoomID == "" { + t.Fatalf("reopen after leaving: %+v %v", reopened, err) + } + if matrix.created != 2 || matrix.deleted != 1 { + t.Fatalf("create/delete counts = %d/%d", matrix.created, matrix.deleted) + } + assertPortalCount(t, lc, 1) } From 1b0db13e25709cb33e11d6a3be9da36f046e23f9 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:32:23 -0400 Subject: [PATCH 16/35] ci: restore bounded test validation and coverage gates --- .github/coverage-baseline.tsv | 11 ++ .github/workflows/deploy.yml | 52 +++++++++ .gitignore | 4 + scripts/check_test_coverage.py | 94 ++++++++++++++++ scripts/measure_tests.py | 164 ++++++++++++++++++++++++++++ scripts/test.sh | 36 ++++++ scripts/test_check_test_coverage.py | 54 +++++++++ 7 files changed, 415 insertions(+) create mode 100644 .github/coverage-baseline.tsv create mode 100755 scripts/check_test_coverage.py create mode 100755 scripts/measure_tests.py create mode 100755 scripts/test.sh create mode 100644 scripts/test_check_test_coverage.py diff --git a/.github/coverage-baseline.tsv b/.github/coverage-baseline.tsv new file mode 100644 index 00000000..695cb04f --- /dev/null +++ b/.github/coverage-baseline.tsv @@ -0,0 +1,11 @@ +# Max of historical development baseline and measured upstream baseline at 913362278cb8d7b39b90e04be3d54b43e3aba843. +# package covered total non_generated_covered non_generated_total +github.com/beeper/line/cmd/matrix-line 0 3 0 3 +github.com/beeper/line/pkg 235 736 235 736 +github.com/beeper/line/pkg/connector 1537 4433 1537 4433 +github.com/beeper/line/pkg/connector/handlers 368 985 368 985 +github.com/beeper/line/pkg/e2ee 223 608 223 608 +github.com/beeper/line/pkg/line 419 1567 419 1567 +github.com/beeper/line/pkg/line/password 0 26 0 26 +github.com/beeper/line/pkg/line/secret 0 7 0 7 +github.com/beeper/line/pkg/ltsm 171289 404716 545 1061 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a575616..29f7f0ca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -125,3 +125,55 @@ jobs: BEEPER_DEV_ADMIN_NIGHTLY_PASS: "${{ secrets.BEEPER_DEV_ADMIN_NIGHTLY_PASS }}" BEEPER_STAGING_ADMIN_NIGHTLY_PASS: "${{ secrets.BEEPER_STAGING_ADMIN_NIGHTLY_PASS }}" BEEPER_PROD_ADMIN_NIGHTLY_PASS: "${{ secrets.BEEPER_PROD_ADMIN_NIGHTLY_PASS }}" + + test: + name: Test (${{ matrix.mode }}) + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + mode: [normal, race, coverage] + env: + GOMAXPROCS: "4" + TEST_PACKAGE_PARALLELISM: "2" + TEST_TIMEOUT: 10m + TEST_JSON: "1" + TEST_OUTPUT_DIR: ${{ github.workspace }}/.opencode-work/test-ci-${{ matrix.mode }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + id: go + with: + go-version: "1.26" + cache: false + - name: Install libolm + run: sudo apt-get update && sudo apt-get install -y libolm-dev + - name: Identify native test ABI + id: native + run: echo "abi=$(gcc -dumpfullversion)-$(pkg-config --modversion olm)" >> "$GITHUB_OUTPUT" + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: test-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + - name: Cache compiled tests by instrumentation mode + uses: actions/cache@v4 + with: + path: ~/.cache/go-build + key: test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go', 'pkg/ltsm/data/*') }} + restore-keys: | + test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}- + - name: Run validation + run: bash scripts/test.sh "${{ matrix.mode }}" + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.mode }} + path: | + ${{ env.TEST_OUTPUT_DIR }}/coverage.out + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index f8945039..bef8bec4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ # internal tools /tools + +# OpenCode-only validation artifacts and continuity records +/.opencode-work/ +/CONTINUITY.md diff --git a/scripts/check_test_coverage.py b/scripts/check_test_coverage.py new file mode 100755 index 00000000..47050e7a --- /dev/null +++ b/scripts/check_test_coverage.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Enforce measured package coverage floors without hiding generated coverage.""" +import argparse +from pathlib import Path +import sys + + +def read_profile(path): + lines = path.read_text().splitlines() + if not lines or lines[0] not in ("mode: set", "mode: count", "mode: atomic"): + raise ValueError("missing or invalid coverage mode") + packages = {} + seen = set() + for line in lines[1:]: + block, statements, count = line.rsplit(" ", 2) + if block in seen: + raise ValueError(f"duplicate coverage block: {block}") + seen.add(block) + filename, _ = block.rsplit(":", 1) + package = filename.rsplit("/", 1)[0] + statements, count = int(statements), int(count) + if statements < 0 or count < 0: + raise ValueError("negative statement or execution count") + # covered, total, non-generated covered, non-generated total + values = packages.setdefault(package, [0, 0, 0, 0]) + values[0] += statements if count else 0 + values[1] += statements + if not filename.endswith("/wbc_generated.go"): + values[2] += statements if count else 0 + values[3] += statements + if not packages: + raise ValueError("empty coverage profile") + return packages + + +def compare(current, baseline): + errors = [] + checks = [(package, current.get(package), expected) for package, expected in baseline.items()] + # New packages must not dilute aggregate coverage while every older package + # still meets its own floor. Keep generated and handwritten scopes separate. + checks.append(("all packages", [sum(row[i] for row in current.values()) for i in range(4)], + [sum(row[i] for row in baseline.values()) for i in range(4)])) + for package, actual, expected in checks: + if actual is None: + errors.append(f"missing package: {package}") + continue + for start, scope in ((0, "all"), (2, "non-generated")): + covered, total = actual[start:start+2] + old_covered, old_total = expected[start:start+2] + if covered < old_covered or covered * old_total < old_covered * total: + errors.append(f"{package} ({scope}): {covered}/{total}, baseline {old_covered}/{old_total}") + return errors + + +def read_baseline(path): + result = {} + for line in path.read_text().splitlines(): + if not line or line.startswith("#"): + continue + package, *counts = line.split() + if len(counts) != 4 or package in result: + raise ValueError("invalid or duplicate baseline package") + values = list(map(int, counts)) + if any(value < 0 for value in values) or values[0] > values[1] or values[2] > values[3]: + raise ValueError("invalid baseline counts") + result[package] = values + if not result: + raise ValueError("empty coverage baseline") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("profile", type=Path) + parser.add_argument("--baseline", type=Path, default=Path(__file__).resolve().parents[1]/".github/coverage-baseline.tsv") + args = parser.parse_args() + try: + actual = read_profile(args.profile) + errors = compare(actual, read_baseline(args.baseline)) + except (OSError, ValueError) as exc: + parser.error(str(exc)) + for package, values in sorted(actual.items()): + percent = 100*values[0]/values[1] if values[1] else 0 + nongenerated = 100*values[2]/values[3] if values[3] else 0 + print(f"{package}: {percent:.3f}% all; {nongenerated:.3f}% non-generated") + if errors: + print("Coverage regressions:\n" + "\n".join(errors), file=sys.stderr) + return 1 + print("All package coverage floors passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/measure_tests.py b/scripts/measure_tests.py new file mode 100755 index 00000000..0f5a44cd --- /dev/null +++ b/scripts/measure_tests.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Measure cold builds and uncached tests serially on Linux (time, bwrap, /proc).""" +import argparse +import json +import os +from pathlib import Path +import resource +import subprocess +import time + + +def tree_stats(root): + processes = {} + for entry in Path('/proc').iterdir(): + if not entry.name.isdigit(): + continue + try: + stat = (entry / 'stat').read_text() + fields = stat[stat.rfind(')') + 2:].split() + processes[int(entry.name)] = (int(fields[1]), int(fields[21]) * os.sysconf('SC_PAGE_SIZE')) + except (OSError, ValueError, IndexError): + continue + selected = {root} + while True: + expanded = selected | {pid for pid, (parent, _) in processes.items() if parent in selected} + if expanded == selected: + break + selected = expanded + return sum(processes.get(pid, (0, 0))[1] for pid in selected), len(selected) + + +def summarize_tests(path): + counts = {'pass': 0, 'fail': 0, 'skip': 0} + tests, packages = [], [] + for raw in path.read_text().splitlines(): + try: + event = json.loads(raw) + except ValueError: + continue + action = event.get('Action') + if action in counts and event.get('Test'): + counts[action] += 1 + tests.append({k: event[k] for k in ('Package', 'Test', 'Action', 'Elapsed') if k in event}) + elif action in ('pass', 'fail', 'skip') and event.get('Package'): + packages.append({k: event[k] for k in ('Package', 'Action', 'Elapsed') if k in event}) + return {'counts': counts, 'packages': sorted(packages, key=lambda x: x.get('Elapsed', 0), reverse=True), + 'tests': sorted(tests, key=lambda x: x.get('Elapsed', 0), reverse=True)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('label', help='New result-directory label; existing results are never overwritten') + parser.add_argument('--repo', type=Path, default=Path.cwd()) + parser.add_argument('--parallel', type=int, default=2) + parser.add_argument('--gomaxprocs', type=int, default=4) + parser.add_argument('--phases', nargs='*') + args = parser.parse_args() + if Path(args.label).name != args.label or args.label in ('.', '..'): + parser.error('label must be a directory name, not a path') + if args.parallel < 1 or args.gomaxprocs < 1: + parser.error('parallelism must be positive') + repo = args.repo.resolve() + if subprocess.check_output(['git', 'status', '--porcelain'], cwd=repo, text=True).strip(): + parser.error('commit source changes before measuring; the benchmark does not stash or discard work') + out = repo / '.opencode-work/test-optimization' / args.label + out.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env.update(GOMAXPROCS=str(args.gomaxprocs), LC_ALL='C') + tmp = out / 'tmp' + tmp.mkdir(exist_ok=True) + env.update(TMPDIR=str(tmp), GOTMPDIR=str(tmp)) + cache = out / 'cache' + cache.mkdir(exist_ok=True) + env['GOCACHE'] = str(cache) + bins = out / 'bins' + for mode in ('normal', 'race', 'coverage'): + (bins / mode).mkdir(parents=True, exist_ok=True) + meta = {'commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip(), + 'go': subprocess.check_output(['go', 'version'], env=env, text=True).strip(), + 'parallel': args.parallel, 'gomaxprocs': args.gomaxprocs, + 'cpu_count': os.cpu_count(), 'platform': list(os.uname()), + 'environment': {k: env.get(k) for k in ('GOCACHE', 'GOPATH', 'GOTOOLCHAIN', 'GOWORK', 'CGO_ENABLED', 'CGO_CFLAGS', 'CGO_LDFLAGS')}, + 'cache_note': 'New per-label build cache; shared prepopulated module download cache. Tests use explicit -count to avoid result caching.', + 'tmp_note': 'bubblewrap redirects historical hard-coded /tmp vector output into this project-local tmp directory.'} + metadata_path = out / 'environment.json' + if metadata_path.exists(): + previous = json.loads(metadata_path.read_text()) + for key in ('commit', 'go', 'parallel', 'gomaxprocs'): + if previous[key] != meta[key]: + parser.error(f'{key} changed; choose a new result label') + else: + metadata_path.write_text(json.dumps(meta, indent=2) + '\n') + p = str(args.parallel) + full = ['-p', p, '-count=1', '-timeout=60m', '-json', './...'] + phases = [ + ('cold-normal-build', ['go', 'test', '-p', p, '-c', '-o', str(bins/'normal'), './...']), + ('warm-normal-build', ['go', 'test', '-p', p, '-c', '-o', str(bins/'normal'), './...']), + ('normal', ['go', 'test', *full]), + ('normal-repeat', ['go', 'test', '-p', p, '-count=2', '-shuffle=271', '-timeout=60m', '-json', './...']), + ('init-normal', ['go', 'test', '-p', p, '-count=1', '-run=^$', '-json', './...']), + ('profile-signing', [str(bins/'normal/pkg.test'), '-test.count=1', '-test.run=^TestGetSignature', '-test.v', '-test.timeout=60m', + '-test.cpuprofile='+str(out/'signing.cpu.pprof'), '-test.memprofile='+str(out/'signing.mem.pprof')]), + ('cold-race-build', ['go', 'test', '-p', p, '-race', '-c', '-o', str(bins/'race'), './...']), + ('warm-race-build', ['go', 'test', '-p', p, '-race', '-c', '-o', str(bins/'race'), './...']), + ('race', ['go', 'test', '-race', *full]), + ('init-race', ['go', 'test', '-race', '-p', p, '-count=1', '-run=^$', '-json', './...']), + ('cold-coverage-build', ['go', 'test', '-p', p, '-cover', '-covermode=atomic', '-c', '-o', str(bins/'coverage'), './...']), + ('coverage', ['go', 'test', '-covermode=atomic', '-coverprofile='+str(out/'coverage.out'), *full]), + ('cold-normal-suite', ['go', 'test', *full]), + ('cold-race-suite', ['go', 'test', '-race', *full]), + ('focus-repeat', ['go', 'test', '-race', '-p', p, '-count=10', '-shuffle=271', '-timeout=60m', '-json', + '-run=^(TestGetSignatureEvictsSigningContexts|TestGetLastOpRevisionContextCancellation)$', './pkg', './pkg/line']), + ] + valid_phases = {name for name, _ in phases} + if args.phases and set(args.phases) - valid_phases: + parser.error('unknown phase: ' + ', '.join(set(args.phases) - valid_phases)) + for name, cmd in phases: + if args.phases and name not in args.phases: + continue + if subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip() != meta['commit'] or subprocess.check_output(['git', 'status', '--porcelain'], cwd=repo, text=True).strip(): + parser.error('source changed during measurement; preserve results and use a new label') + result_path = out / (name + '.summary.json') + if result_path.exists(): + if json.loads(result_path.read_text())['exit_status'] != 0: + parser.error(f'{name} previously failed; preserve it and use a new label for reruns') + print('Already measured: ' + name, flush=True) + continue + cwd = repo / 'pkg' if name == 'profile-signing' else repo + phase_env = env.copy() + if name in ('cold-normal-suite', 'cold-race-suite'): + cold_cache = out / (name + '-cache') + cold_cache.mkdir(exist_ok=True) + phase_env['GOCACHE'] = str(cold_cache) + wrapped = ['bwrap', '--bind', '/', '/', '--dev', '/dev', '--bind', str(tmp), '/tmp', '--chdir', str(cwd), '--', *cmd] + print('Starting ' + name + ': ' + ' '.join(cmd), flush=True) + started = time.monotonic() + load_start = os.getloadavg() + usage_before = resource.getrusage(resource.RUSAGE_CHILDREN) + peak_rss, peak_processes = 0, 0 + stdout_path = out / (name + '.jsonl') + with stdout_path.open('w') as stdout, (out / (name + '.stderr')).open('w') as stderr: + proc = subprocess.Popen(['/usr/bin/time', '-v', '-o', str(out/(name+'.time.txt')), *wrapped], cwd=repo, env=phase_env, stdout=stdout, stderr=stderr) + while proc.poll() is None: + rss, count = tree_stats(proc.pid) + peak_rss, peak_processes = max(peak_rss, rss), max(peak_processes, count) + time.sleep(0.1) + elapsed = time.monotonic() - started + usage_after = resource.getrusage(resource.RUSAGE_CHILDREN) + user = usage_after.ru_utime - usage_before.ru_utime + system = usage_after.ru_stime - usage_before.ru_stime + result = {'phase': name, 'command': cmd, 'build_cache': phase_env['GOCACHE'], 'exit_status': proc.returncode, + 'wall_seconds': elapsed, 'user_seconds': user, 'system_seconds': system, + 'cpu_percent': 100*(user+system)/elapsed, 'peak_tree_rss_bytes': peak_rss, + 'peak_processes': peak_processes, 'load_average_start': load_start, + 'load_average_end': os.getloadavg(), **summarize_tests(stdout_path)} + result_path.write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps({k: result[k] for k in ('phase', 'exit_status', 'wall_seconds', 'cpu_percent', 'peak_tree_rss_bytes', 'counts')}), flush=True) + if proc.returncode != 0: + raise SystemExit(proc.returncode) + (out/'complete.json').write_text(json.dumps({'completed_at': time.time(), 'commit': meta['commit']}) + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 00000000..9e17b299 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(git rev-parse --show-toplevel) +cd "$root" +mode=${1:-normal}; if (($#)); then shift; fi +parallel=${TEST_PACKAGE_PARALLELISM:-2}; timeout=${TEST_TIMEOUT:-10m} +out=${TEST_OUTPUT_DIR:-"$root/.opencode-work/test-optimization/validation"} +mkdir -p "$out/tmp"; export TMPDIR="$out/tmp" GOTMPDIR="$out/tmp" +export GOMAXPROCS=${GOMAXPROCS:-4} +json=(); [[ ${TEST_JSON:-0} == 1 ]] && json=(-json) +format_check() { + local files; files=$(go list -f '{{range .GoFiles}}{{$.Dir}}/{{.}}{{"\n"}}{{end}}{{range .TestGoFiles}}{{$.Dir}}/{{.}}{{"\n"}}{{end}}{{range .XTestGoFiles}}{{$.Dir}}/{{.}}{{"\n"}}{{end}}' "$@") + [[ -z $(gofmt -l $files) ]] || { gofmt -l $files; return 1; } +} +lint() { + local packages=() package + while IFS= read -r package; do [[ $package == */ltsm ]] || packages+=("$package"); done < <(go list "$@") + ((${#packages[@]})) && { go vet "${packages[@]}"; staticcheck "${packages[@]}"; } +} +normal() { go test -p "$parallel" -count=1 -timeout="$timeout" "${json[@]}" ./...; } +race() { go test -race -p "$parallel" -count=1 -timeout="$timeout" "${json[@]}" ./...; } +coverage() { go test -p "$parallel" -count=1 -timeout="$timeout" -covermode=atomic -coverprofile="$out/coverage.out" "${json[@]}" ./...; python3 -B scripts/check_test_coverage.py "$out/coverage.out"; } +case $mode in + fast) (($#)) || { echo 'usage: scripts/test.sh fast ./pkg'; exit 2; }; format_check "$@"; lint "$@"; run=(); [[ -z ${TEST_RUN:-} ]] || run=(-run "$TEST_RUN"); go test -p "$parallel" -count=1 -timeout="$timeout" "${json[@]}" "${run[@]}" "$@" ;; + normal) normal ;; + race) race ;; + coverage) coverage ;; + release) + format_check ./...; lint ./...; go mod verify; python3 -B -m unittest discover -s scripts -p test_check_test_coverage.py + normal; race; coverage; go build ./... + source_epoch=${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)} + SOURCE_DATE_EPOCH=$source_epoch ./build.sh -o "$out/matrix-line" + "$out/matrix-line" --version + ;; + *) echo 'Modes: fast, normal, race, coverage, release'; exit 2 ;; +esac diff --git a/scripts/test_check_test_coverage.py b/scripts/test_check_test_coverage.py new file mode 100644 index 00000000..7be6d30c --- /dev/null +++ b/scripts/test_check_test_coverage.py @@ -0,0 +1,54 @@ +import tempfile +from pathlib import Path +import unittest + +from check_test_coverage import compare, read_baseline, read_profile + + +class CoverageGateTests(unittest.TestCase): + def profile(self, content): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/"coverage.out" + path.write_text(content) + return read_profile(path) + + def test_counts_both_scopes(self): + values = self.profile("mode: atomic\nexample/pkg/a.go:1.1,2.2 3 4\nexample/pkg/wbc_generated.go:1.1,2.2 100 0\n") + self.assertEqual(values, {"example/pkg": [3, 103, 3, 3]}) + + def test_unchanged_and_improved_pass(self): + baseline = {"pkg": [3, 10, 2, 5]} + self.assertEqual(compare(baseline, baseline), []) + self.assertEqual(compare({"pkg": [4, 10, 3, 5]}, baseline), []) + + def test_missing_package_fails(self): + self.assertTrue(compare({}, {"pkg": [0, 1, 0, 1]})) + + def test_covered_count_and_fraction_cannot_drop(self): + baseline = {"pkg": [5, 10, 5, 10]} + self.assertTrue(compare({"pkg": [4, 8, 4, 8]}, baseline)) + self.assertTrue(compare({"pkg": [5, 11, 5, 11]}, baseline)) + + def test_generated_gains_cannot_mask_handwritten_loss(self): + self.assertTrue(compare({"pkg": [100, 100, 4, 10]}, {"pkg": [50, 100, 5, 10]})) + + def test_new_uncovered_package_cannot_dilute_total_coverage(self): + baseline = {"pkg": [5, 10, 5, 10]} + current = {**baseline, "new/pkg": [0, 100, 0, 100]} + self.assertTrue(compare(current, baseline)) + + def test_invalid_profiles_fail_closed(self): + for content in ("", "mode: atomic\n", "mode: unknown\n", "mode: atomic\nx/a.go:1.1,2.2 -1 1\n", "mode: atomic\nx/a.go:1.1,2.2 1 1\nx/a.go:1.1,2.2 1 1\n"): + with self.subTest(content=content), self.assertRaises(ValueError): + self.profile(content) + + def test_empty_baseline_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/"baseline.tsv" + path.write_text("# no entries\n") + with self.assertRaises(ValueError): + read_baseline(path) + + +if __name__ == "__main__": + unittest.main() From e18d9f4169ea7e3684fafacc72c7c3e3093bb016 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:33:54 -0400 Subject: [PATCH 17/35] fix: serialize account logins and classify session failures --- pkg/connector/connector.go | 77 +++++++++++++++++++++-- pkg/connector/login_cooldown.go | 48 +++++++++++--- pkg/connector/login_cooldown_test.go | 20 +++++- pkg/connector/login_error_logging_test.go | 16 +++++ pkg/connector/login_qr.go | 4 ++ pkg/connector/loginerrors.go | 42 ++++++++++++- 6 files changed, 190 insertions(+), 17 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 95f804c8..241d3212 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -184,12 +184,12 @@ func (lc *LineConnector) GetLoginFlows() []bridgev2.LoginFlow { func (lc *LineConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { if flowID == LoginFlowIDQR && lc.Config.QRLogin { - return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, cooldown: &lc.loginCooldown}}, nil + return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, cooldown: &lc.loginCooldown, authFlow: "qr"}}, nil } if flowID != LoginFlowIDEmail { return nil, bridgev2.ErrInvalidLoginFlowID } - return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin, cooldown: &lc.loginCooldown}, nil + return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin, cooldown: &lc.loginCooldown, authFlow: "email"}, nil } type LineEmailLogin struct { @@ -206,6 +206,7 @@ type LineEmailLogin struct { qrEnabled bool qrLogin *LineQRLogin cooldown *loginCooldown + authFlow string pollResult chan *line.LoginResult pollErr chan error @@ -214,10 +215,42 @@ type LineEmailLogin struct { attemptCtx context.Context attemptCancel context.CancelFunc canceled bool + loginClaimed bool mu sync.Mutex finalizeMu *sync.Mutex } +func (ll *LineEmailLogin) claimLogin() error { + if ll.cooldown == nil || ll.User == nil { + return nil + } + ll.mu.Lock() + alreadyClaimed := ll.loginClaimed + ll.mu.Unlock() + if alreadyClaimed || !ll.cooldown.acquire(string(ll.User.MXID)) { + return ErrLoginInProgress + } + ll.mu.Lock() + if ll.loginClaimed { + ll.mu.Unlock() + ll.cooldown.release(string(ll.User.MXID)) + return ErrLoginInProgress + } + ll.loginClaimed = true + ll.mu.Unlock() + return nil +} + +func (ll *LineEmailLogin) releaseLogin() { + ll.mu.Lock() + claimed := ll.loginClaimed + ll.loginClaimed = false + ll.mu.Unlock() + if claimed && ll.cooldown != nil && ll.User != nil { + ll.cooldown.release(string(ll.User.MXID)) + } +} + var _ bridgev2.LoginProcessUserInput = (*LineEmailLogin)(nil) var _ bridgev2.LoginProcessDisplayAndWait = (*LineEmailLogin)(nil) var _ bridgev2.LoginProcessWithOverride = (*LineEmailLogin)(nil) @@ -261,6 +294,7 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg if ll.Email == "" || ll.Password == "" { if ll.qrEnabled { ll.Email, ll.Password = "", "" + ll.authFlow = "qr" ll.mu.Lock() ll.qrLogin = &LineQRLogin{login: ll} qrLogin := ll.qrLogin @@ -282,9 +316,13 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg if err := ll.loginCooldownError(); err != nil { return nil, err } + if err := ll.claimLogin(); err != nil { + return nil, err + } override.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { + ll.releaseLogin() ll.logLoginFailure(err, "reconnect") ll.noteLoginRateLimit(err) reason := loginErrorReason(err) @@ -293,7 +331,11 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg } return ll.loginErrorStep(reason), nil } - return ll.handleLoginResponse(ctx, res) + step, err := ll.handleLoginResponse(ctx, res) + if err != nil { + ll.releaseLogin() + } + return step, err } func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { @@ -314,8 +356,12 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] if err := ll.loginCooldownError(); err != nil { return nil, err } + if err := ll.claimLogin(); err != nil { + return nil, err + } res, err := ll.loginCredentials(ctx, "") if err != nil { + ll.releaseLogin() ll.logLoginFailure(err, "credentials") ll.noteLoginRateLimit(err) reason := loginErrorReason(err) @@ -325,7 +371,11 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] return ll.loginErrorStep(reason), nil } - return ll.handleLoginResponse(ctx, res) + step, err := ll.handleLoginResponse(ctx, res) + if err != nil { + ll.releaseLogin() + } + return step, err } func (ll *LineEmailLogin) loginErrorStep(message string) *bridgev2.LoginStep { @@ -487,6 +537,12 @@ func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { } var authErr *line.AuthError if errors.As(wrapLineLoginError(err), &authErr) { + event.Str("auth_error_flow", authErr.Flow). + Str("auth_error_stage", authErr.Stage.String()). + Str("auth_error_category", authErr.Category.String()). + Bool("retry_recommended", authErr.RetryRecommended). + Bool("temporary", authErr.Temporary). + Bool("cooldown_triggered", authErr.CooldownTriggered) if authErr.ProviderMessage != "" { event.Str("provider_message", authErr.ProviderMessage) } @@ -624,10 +680,12 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) return ll.finishLogin(ctx, res) } res.Attempt.Close() + ll.releaseLogin() return nil, ErrLoginVerificationFailed case err := <-errCh: ll.logLoginFailure(err, "verification_poll") ll.noteLoginRateLimit(err) + ll.releaseLogin() return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) case <-done: return nil, context.Canceled @@ -645,9 +703,14 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) if err != nil { ll.logLoginFailure(err, "pin_continuation") ll.noteLoginRateLimit(err) + ll.releaseLogin() return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) } - return ll.handleLoginResponse(ctx, res) + step, err := ll.handleLoginResponse(ctx, res) + if err != nil { + ll.releaseLogin() + } + return step, err } return nil, fmt.Errorf("no pending login continuation") @@ -754,6 +817,7 @@ func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.Log } func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult) (*bridgev2.LoginStep, error) { + defer ll.releaseLogin() if res != nil { defer res.Attempt.Close() } @@ -796,7 +860,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult client.LoginAttempt = res.Attempt profile, err := client.GetProfileContext(ctx) if err != nil { - return nil, fmt.Errorf("failed to verify token: %w", err) + return nil, wrapSessionEstablishError(ll, fmt.Errorf("failed to verify token: %w", err)) } displayName := profile.DisplayName @@ -1068,4 +1132,5 @@ func (ll *LineEmailLogin) Cancel() { if attempt != nil { attempt.Close() } + ll.releaseLogin() } diff --git a/pkg/connector/login_cooldown.go b/pkg/connector/login_cooldown.go index 020faf58..9a5e08ca 100644 --- a/pkg/connector/login_cooldown.go +++ b/pkg/connector/login_cooldown.go @@ -40,8 +40,34 @@ const ( // in-memory on purpose: the limit is enforced by LINE, so the bridge only // needs to avoid making it worse. type loginCooldown struct { - mu sync.Mutex - until map[string]time.Time + mu sync.Mutex + until map[string]time.Time + active map[string]bool +} + +func (c *loginCooldown) acquire(userID string) bool { + if c == nil || userID == "" { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + if c.active == nil { + c.active = make(map[string]bool) + } + if c.active[userID] { + return false + } + c.active[userID] = true + return true +} + +func (c *loginCooldown) release(userID string) { + if c == nil || userID == "" { + return + } + c.mu.Lock() + delete(c.active, userID) + c.mu.Unlock() } // block stops new login attempts for the account until the given time. @@ -155,17 +181,21 @@ func (ll *LineEmailLogin) noteLoginRateLimit(err error) { } now := time.Now() var authErr *line.AuthError - if errors.As(err, &authErr) && authErr.RetryAfter != nil { - ll.cooldown.block(string(ll.User.MXID), now.Add(*authErr.RetryAfter)) - return - } + _ = errors.As(err, &authErr) + var delay time.Duration switch classifyLoginRateLimit(err) { case loginRateLimitLocked: - ll.cooldown.block(string(ll.User.MXID), now.Add(loginRateLimitCooldown)) + delay = loginRateLimitCooldown case loginRateLimitRetryLater: - ll.cooldown.block(string(ll.User.MXID), now.Add(loginRetryLaterCooldown)) + delay = loginRetryLaterCooldown case loginRateLimitVerificationUnavailable: - ll.cooldown.block(string(ll.User.MXID), now.Add(verificationTemporarilyUnavailableCooldown)) + delay = verificationTemporarilyUnavailableCooldown + } + if authErr != nil && authErr.RetryAfter != nil && *authErr.RetryAfter > delay { + delay = *authErr.RetryAfter + } + if delay > 0 { + ll.cooldown.block(string(ll.User.MXID), now.Add(delay)) } } diff --git a/pkg/connector/login_cooldown_test.go b/pkg/connector/login_cooldown_test.go index 93c4f2a1..2248dc68 100644 --- a/pkg/connector/login_cooldown_test.go +++ b/pkg/connector/login_cooldown_test.go @@ -251,11 +251,29 @@ func TestLoginCooldownExpires(t *testing.T) { } } +func TestLoginClaimSerializesSameAccount(t *testing.T) { + var cooldown loginCooldown + user := &bridgev2.User{User: &database.User{MXID: "@same:example.com"}} + first := &LineEmailLogin{User: user, cooldown: &cooldown} + second := &LineEmailLogin{User: user, cooldown: &cooldown} + if err := first.claimLogin(); err != nil { + t.Fatal(err) + } + if err := second.claimLogin(); !errors.Is(err, ErrLoginInProgress) { + t.Fatalf("concurrent login claim = %v, want ErrLoginInProgress", err) + } + first.releaseLogin() + if err := second.claimLogin(); err != nil { + t.Fatalf("claim after release = %v", err) + } + second.releaseLogin() +} + func TestLoginCooldownUsesRetryAfter(t *testing.T) { var cooldown loginCooldown user := &bridgev2.User{User: &database.User{MXID: "@user:example.com"}} ll := &LineEmailLogin{User: user, cooldown: &cooldown} - delay := 37 * time.Second + delay := 20 * time.Minute ll.noteLoginRateLimit(&line.AuthError{Category: line.AuthErrorTooManyAttempts, RetryAfter: &delay}) if remaining := cooldown.remaining(string(user.MXID)); remaining < delay-time.Second || remaining > delay { t.Fatalf("cooldown=%s, want provider Retry-After %s", remaining, delay) diff --git a/pkg/connector/login_error_logging_test.go b/pkg/connector/login_error_logging_test.go index d34e9e65..c3f5a0d4 100644 --- a/pkg/connector/login_error_logging_test.go +++ b/pkg/connector/login_error_logging_test.go @@ -88,6 +88,22 @@ func TestParseLoginErrorDetailsWithoutJSON(t *testing.T) { } } +func TestSessionEstablishmentErrorKeepsStageAndSafeMessage(t *testing.T) { + err := errors.New(`API error 503: {"code":10051,"data":{"name":"LoginException","message":"LINE could not verify this session. Please try again."}}`) + wrapped := wrapSessionEstablishError(&LineEmailLogin{authFlow: "qr"}, err) + var authErr *line.AuthError + if !errors.As(wrapped, &authErr) { + t.Fatalf("session failure lacks AuthError: %v", wrapped) + } + if authErr.Flow != "qr" || authErr.Stage != line.AuthStageSessionEstablish || authErr.Category != line.AuthErrorServiceUnavailable { + t.Fatalf("incorrect session classification: %+v", authErr) + } + var respErr bridgev2.RespError + if !errors.As(wrapped, &respErr) || !strings.Contains(respErr.Err, "LINE could not verify this session") { + t.Fatalf("safe provider message did not reach Matrix: %v", wrapped) + } +} + func TestLoginErrorSummaryDoesNotIncludeNonJSONResponseBody(t *testing.T) { err := errors.New("login failed: API error 502: upstream secret response") details := parseLoginErrorDetails(err) diff --git a/pkg/connector/login_qr.go b/pkg/connector/login_qr.go index bed713cc..b062f2a1 100644 --- a/pkg/connector/login_qr.go +++ b/pkg/connector/login_qr.go @@ -56,10 +56,14 @@ func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) { if err := lq.login.loginCooldownError(); err != nil { return nil, err } + if err := lq.login.claimLogin(); err != nil { + return nil, err + } ll := lq.login ll.mu.Lock() if ll.canceled || ll.attemptCtx != nil { ll.mu.Unlock() + ll.releaseLogin() return nil, fmt.Errorf("QR login is already started or canceled") } ll.attemptCtx, ll.attemptCancel = context.WithTimeout(context.WithoutCancel(ctx), 10*time.Minute) diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index ce4ae6c1..4ca94d56 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net" "net/http" "strings" "time" @@ -40,6 +41,11 @@ var ( Err: "LINE is limiting how often this account can sign in. Please try again later.", StatusCode: http.StatusTooManyRequests, } + ErrLoginInProgress = bridgev2.RespError{ + ErrCode: "DEV.HIGHEST.LINE.LOGIN_IN_PROGRESS", + Err: "A LINE sign-in is already in progress for this account. Continue it or cancel it before starting another.", + StatusCode: http.StatusTooManyRequests, + } ErrLoginRejected = bridgev2.RespError{ ErrCode: "DEV.HIGHEST.LINE.LOGIN_REJECTED", Err: genericLoginFailureReason, @@ -122,7 +128,7 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { ExceptionCode: intPtr(details.ErrorCode), Category: cat, ProviderReason: line.SanitizeProviderAlert(reason), - ProviderAlertStatus: providerMessageStatus, + ProviderAlertStatus: "absent", ProviderMessage: providerMessage, ProviderMessageStatus: providerMessageStatus, UserMessage: mapped.Err, @@ -139,6 +145,40 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { return fmt.Errorf("%w: %w", ae, mapped) } +func wrapSessionEstablishError(login *LineEmailLogin, err error) error { + if err == nil { + return nil + } + details := parseLoginErrorDetails(err) + providerMessage, messageStatus := line.SanitizeProviderMessage(providerLoginMessage(details)) + category := line.AuthErrorUnknown + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || errors.As(err, &networkErr) { + category = line.AuthErrorNetworkUnavailable + } else if details.HTTPStatus >= http.StatusInternalServerError { + category = line.AuthErrorServiceUnavailable + } + flow := "email" + if login != nil && login.authFlow != "" { + flow = login.authFlow + } + userMessage := "LINE accepted sign-in, but the bridge could not verify the new session. Please try again." + if messageStatus == "recognized" || messageStatus == "partially_redacted" || messageStatus == "safe_unrecognized" { + userMessage = providerMessage + } + authErr := &line.AuthError{ + Flow: flow, Stage: line.AuthStageSessionEstablish, HTTPStatus: details.HTTPStatus, + ResponseCode: intPtr(details.ResponseCode), ExceptionType: details.ErrorName, + ExceptionCode: intPtr(details.ErrorCode), Category: category, + ProviderReason: line.SanitizeProviderAlert(providerLoginMessage(details)), + ProviderMessage: providerMessage, ProviderMessageStatus: messageStatus, + UserMessage: userMessage, RetryRecommended: category == line.AuthErrorNetworkUnavailable || category == line.AuthErrorServiceUnavailable, + Temporary: category == line.AuthErrorNetworkUnavailable || category == line.AuthErrorServiceUnavailable, + UserActionRequired: true, Err: err, + } + return fmt.Errorf("%w: %w", authErr, bridgev2.RespError(mautrix.MUnknown.WithMessage("%s", userMessage))) +} + // classifyEmailError classifies an email/login error into an AuthErrorCategory. func classifyEmailError(details loginErrorDetails, reason string) line.AuthErrorCategory { safeCategory := line.SanitizeProviderAlert(reason) From 46a2e7903e5b65c30ee40b870719faecff779a7f Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:33:59 -0400 Subject: [PATCH 18/35] fix: bound provider text and avoid empty contact searches --- pkg/connector/resolve_identifier_test.go | 13 +++++++++++++ pkg/connector/userinfo.go | 3 +++ pkg/line/auth_error.go | 4 ++-- pkg/line/auth_error_test.go | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pkg/connector/resolve_identifier_test.go b/pkg/connector/resolve_identifier_test.go index 5f550128..e357af4f 100644 --- a/pkg/connector/resolve_identifier_test.go +++ b/pkg/connector/resolve_identifier_test.go @@ -276,6 +276,19 @@ func TestSearchUsersRejectsUnrequestedContactMID(t *testing.T) { } } +func TestSearchUsersEmptyQueryDoesNotEnumerateContacts(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("empty search made LINE request: %s", req.URL.Path) + return nil, nil + }) + for _, query := range []string{"", " \t\n "} { + results, err := lc.SearchUsers(context.Background(), query) + if err != nil || len(results) != 0 { + t.Fatalf("empty query %q: results=%v err=%v", query, results, err) + } + } +} + func TestResolveIdentifierFailuresDoNotPersistPortals(t *testing.T) { for _, tc := range []struct { name, query, response string diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 88c23970..eb66fe23 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -358,6 +358,9 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev // Exact public ID and MID lookup is shared with start-chat. Only search // falls back to non-unique display-name matches. lowerQuery := strings.ToLower(strings.TrimSpace(query)) + if lowerQuery == "" { + return nil, nil + } if lowerQuery != "" { contact, err := lc.resolveContactIdentifier(ctx, query) exactErr = err diff --git a/pkg/line/auth_error.go b/pkg/line/auth_error.go index 52a7a5a6..f721797c 100644 --- a/pkg/line/auth_error.go +++ b/pkg/line/auth_error.go @@ -171,13 +171,12 @@ func (e *AuthError) Unwrap() error { return e.Err } -// SafeAlertStatus returns a safe provider alert status string. Never returns -// raw provider text. var ( providerCredentialPattern = regexp.MustCompile(`(?i)\b(access[_ -]?token|refresh[_ -]?token|id[_ -]?token|password|passwd|authorization|cookie|authsessionid|session[_ -]?id|qr[_ -]?(token|secret)|credential|secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;.!?]+)([.!?]?)`) providerEmailPattern = regexp.MustCompile(`(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b`) providerJWTpattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b`) providerOpaqueMIDPattern = regexp.MustCompile(`\bU[A-Za-z0-9_-]{43}\b`) + providerLongSecretPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{32,}\b`) ) // SanitizeProviderMessage keeps useful provider wording while removing common @@ -202,6 +201,7 @@ func SanitizeProviderMessage(message string) (safe, status string) { message = providerEmailPattern.ReplaceAllString(message, "[redacted email]") message = providerJWTpattern.ReplaceAllString(message, "[redacted token]") message = providerOpaqueMIDPattern.ReplaceAllString(message, "[redacted LINE ID]") + message = providerLongSecretPattern.ReplaceAllString(message, "[redacted token]") if strings.ContainsAny(message, "{}") || strings.Contains(strings.ToLower(message), "bearer ") || strings.Contains(message, "https://") || strings.Contains(message, "http://") { return "", "suppressed" } diff --git a/pkg/line/auth_error_test.go b/pkg/line/auth_error_test.go index 5cdbd546..5b9e064b 100644 --- a/pkg/line/auth_error_test.go +++ b/pkg/line/auth_error_test.go @@ -9,6 +9,7 @@ func TestSanitizeProviderMessage(t *testing.T) { {"verified verification alert", "Verification is temporarily unavailable.\nPlease try again later.", "Verification is temporarily unavailable.\nPlease try again later.", "recognized"}, {"token field redacted", "Login failed; access_token=private-value. Please try later.", "Login failed; access_token=[redacted]. Please try later.", "partially_redacted"}, {"email redacted", "Account for person@example.org is locked.", "Account for [redacted email] is locked.", "partially_redacted"}, + {"opaque token redacted", "Login failed: abcdefghijklmnopqrstuvwxyz0123456789, please retry.", "Login failed: [redacted token], please retry.", "partially_redacted"}, {"bounded safe provider text", "LINE could not complete this sign-in. Try again later.", "LINE could not complete this sign-in. Try again later.", "safe_unrecognized"}, {"structured response suppressed", `{"token":"private"}`, "", "suppressed"}, {"control sequence suppressed", "safe text\x1b[31mprivate", "", "suppressed"}, From dba644c2b209289ac7dac4d95e5920967eec247b Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:36:57 -0400 Subject: [PATCH 19/35] test: cover upstream E2EE and media metadata paths --- pkg/connector/handlers/image_test.go | 21 +++++++++++++++++++++ pkg/e2ee/manager_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index 9931c772..e988e81e 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -23,6 +23,27 @@ func TestLineImageDownloadSourcePrefersPublicResource(t *testing.T) { } } +func TestLineMediaCategoryControlsPlainMediaDownload(t *testing.T) { + for _, tc := range []struct { + name, metadata, wantTID string + }{ + {"original media", `{"category":"original"}`, "original"}, + {"other media", `{"category":"preview"}`, ""}, + {"malformed metadata", "{", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + metadata := map[string]string{"MEDIA_CONTENT_INFO": tc.metadata} + options := lineOBSDownloadOptions(metadata, true) + if options.TID != tc.wantTID { + t.Fatalf("download TID = %q, want %q", options.TID, tc.wantTID) + } + }) + } + if got := lineMediaCategory(nil); got != "" { + t.Fatalf("nil metadata category = %q", got) + } +} + func TestLineImageDownloadSourcePrivateAndPlainFallbacks(t *testing.T) { privateSource := lineImageDownloadSource(line.Message{ ID: "message-id", diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go index c5934abb..23f6aa44 100644 --- a/pkg/e2ee/manager_test.go +++ b/pkg/e2ee/manager_test.go @@ -39,6 +39,9 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { if err != nil { t.Fatal(err) } + if version := ChunksVersion(chunks); version != 1 { + t.Fatalf("V1 chunks version = %d, want 1", version) + } msg := &line.Message{Chunks: chunks, From: "u-sender", To: "u-receiver"} for i := 0; i < 1800; i++ { got, err := m.DecryptMessageV2(msg) @@ -52,6 +55,23 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { } m.groupKeys["c-test"] = map[int]int{myRaw: myKey} + m.latestGroupKey["c-test"] = myRaw + m.groupSpecVersion["c-test"] = 1 + groupChunks, err := m.EncryptGroupMessageRaw("c-test", "u-sender", 0, []byte(plaintext)) + if err != nil { + t.Fatalf("spec-version-1 group encryption: %v", err) + } + if version := ChunksVersion(groupChunks); version != 1 { + t.Fatalf("spec-version-1 group chunks version = %d, want 1", version) + } + m.groupSpecVersion["c-test"] = 2 + groupChunks, err = m.EncryptGroupMessageRaw("c-test", "u-sender", 0, []byte(plaintext)) + if err != nil { + t.Fatalf("spec-version-2 group encryption: %v", err) + } + if version := ChunksVersion(groupChunks); version != 2 { + t.Fatalf("spec-version-2 group chunks version = %d, want 2", version) + } got, groupKey, err := m.DecryptGroupMessage(msg, "c-test") if err != nil || got != plaintext || groupKey != myRaw { t.Fatalf("group V1 decrypt = (%q, %d, %v)", got, groupKey, err) @@ -91,6 +111,14 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { } } +func TestChunksVersionDefaultsMalformedAndEmptyInputToV2(t *testing.T) { + for _, chunks := range [][]string{nil, {}, {"not-base64"}} { + if version := ChunksVersion(chunks); version != 2 { + t.Fatalf("ChunksVersion(%v) = %d, want 2", chunks, version) + } + } +} + func TestIsFatalLTSMError(t *testing.T) { if !isFatalLTSMError(ltsm.ErrAbort) { t.Fatal("direct LTSM abort was not classified as fatal") From 0b151e020b22b0603f90db16bf763c410c9a757d Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:38:16 -0400 Subject: [PATCH 20/35] ci: keep test caches inside the isolated work area --- .github/workflows/deploy.yml | 6 ++++-- scripts/test.sh | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 29f7f0ca..94cdd9a7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -142,6 +142,8 @@ jobs: TEST_TIMEOUT: 10m TEST_JSON: "1" TEST_OUTPUT_DIR: ${{ github.workspace }}/.opencode-work/test-ci-${{ matrix.mode }} + GOPATH: ${{ github.workspace }}/.opencode-work/gopath + GOCACHE: ${{ github.workspace }}/.opencode-work/go-build-cache steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -157,12 +159,12 @@ jobs: - name: Cache Go modules uses: actions/cache@v4 with: - path: ~/go/pkg/mod + path: ${{ env.GOPATH }}/pkg/mod key: test-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} - name: Cache compiled tests by instrumentation mode uses: actions/cache@v4 with: - path: ~/.cache/go-build + path: ${{ env.GOCACHE }} key: test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go', 'pkg/ltsm/data/*') }} restore-keys: | test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}- diff --git a/scripts/test.sh b/scripts/test.sh index 9e17b299..81101b9a 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -5,7 +5,9 @@ cd "$root" mode=${1:-normal}; if (($#)); then shift; fi parallel=${TEST_PACKAGE_PARALLELISM:-2}; timeout=${TEST_TIMEOUT:-10m} out=${TEST_OUTPUT_DIR:-"$root/.opencode-work/test-optimization/validation"} -mkdir -p "$out/tmp"; export TMPDIR="$out/tmp" GOTMPDIR="$out/tmp" +export GOPATH=${GOPATH:-"$root/.opencode-work/gopath"} +export GOCACHE=${GOCACHE:-"$root/.opencode-work/go-build-cache"} +mkdir -p "$out/tmp" "$GOPATH" "$GOCACHE"; export TMPDIR="$out/tmp" GOTMPDIR="$out/tmp" export GOMAXPROCS=${GOMAXPROCS:-4} json=(); [[ ${TEST_JSON:-0} == 1 ]] && json=(-json) format_check() { From c1d2fad2c6aeecae2e02d14ce2e8d8e4a3dae0e5 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:40:54 -0400 Subject: [PATCH 21/35] fix: enforce cooldowns on background token recovery --- pkg/connector/client.go | 23 ++++++++++++++-- pkg/connector/login_cooldown.go | 17 +++++++----- pkg/connector/login_cooldown_test.go | 39 +++++++++++++++++++++++++++- pkg/connector/loginerrors.go | 2 +- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 4109829e..0a0a6648 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -742,13 +742,22 @@ func (lc *LineClient) Connect(ctx context.Context) { workersStarted = true } -func (lc *LineClient) tryLogin(ctx context.Context) error { +func (lc *LineClient) tryLogin(ctx context.Context) (resultErr error) { var email, password, certificate string + var loginProcess *LineEmailLogin if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok { email = meta.Email password = meta.Password certificate = meta.Certificate } + if lc.UserLogin.Bridge != nil { + if network, ok := lc.UserLogin.Bridge.Network.(*LineConnector); ok { + loginProcess = &LineEmailLogin{User: lc.UserLogin.User, cooldown: &network.loginCooldown} + if err := loginProcess.loginCooldownError(); err != nil { + return err + } + } + } if email == "" || password == "" { if lc.UserLogin.Bridge != nil { @@ -758,9 +767,19 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } return fmt.Errorf("no stored credentials available for re-login") } + if loginProcess != nil { + if err := loginProcess.claimLogin(); err != nil { + return err + } + defer loginProcess.releaseLogin() + defer func() { + if resultErr != nil { + loginProcess.noteLoginRateLimit(resultErr) + } + }() + } lc.UserLogin.Bridge.Log.Info(). - Str("email", email). Bool("has_certificate", certificate != ""). Msg("Attempting to login with email/password...") res, err := loginWithCredentialsContext(ctx, email, password, certificate) diff --git a/pkg/connector/login_cooldown.go b/pkg/connector/login_cooldown.go index 9a5e08ca..3e039a64 100644 --- a/pkg/connector/login_cooldown.go +++ b/pkg/connector/login_cooldown.go @@ -158,19 +158,24 @@ func classifyLoginRateLimit(err error) loginRateLimitKind { if isVerificationTemporarilyUnavailableText(details.ErrorReason) || isVerificationTemporarilyUnavailableText(details.ErrorMessage) { return loginRateLimitVerificationUnavailable } + if isRetryLaterText(details.ErrorReason) || isRetryLaterText(details.ErrorMessage) { + return loginRateLimitRetryLater + } return loginRateLimitNone } func isVerificationTemporarilyUnavailableText(text string) bool { - return strings.EqualFold(strings.TrimSpace(text), "verification is temporarily unavailable") + return line.SanitizeProviderAlert(text) == "verification_temporarily_unavailable" } func isLoginRateLimitText(text string) bool { - switch strings.ToLower(strings.TrimSpace(text)) { - case "too many login attempts", "blocked user": - return true - } - return false + category := line.SanitizeProviderAlert(text) + return category == "too_many_login_attempts" || category == "blocked_user" +} + +func isRetryLaterText(text string) bool { + text = strings.TrimSpace(text) + return strings.EqualFold(text, "retry later") || strings.EqualFold(text, "RETRY_LATER") } // noteLoginRateLimit records a rate limit rejection so the next attempt waits diff --git a/pkg/connector/login_cooldown_test.go b/pkg/connector/login_cooldown_test.go index 2248dc68..db1d14db 100644 --- a/pkg/connector/login_cooldown_test.go +++ b/pkg/connector/login_cooldown_test.go @@ -157,7 +157,7 @@ func TestVerificationTemporarilyUnavailableBlocksRetry(t *testing.T) { oldLogin := loginWithCredentials t.Cleanup(func() { loginWithCredentials = oldLogin }) loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { - return nil, errors.New(`login failed: API error 400: {"code":10051,"data":{"name":"TalkException","reason":"Verification is temporarily unavailable"}}`) + return nil, errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"Verification is temporarily unavailable.\\nPlease try again later.\"}}") } logger := zerolog.New(io.Discard) var cooldown loginCooldown @@ -236,6 +236,43 @@ func TestEmailLoginRateLimitBlocksNextAttempt(t *testing.T) { } } +func TestStoredCredentialRecoveryRespectsActiveCooldown(t *testing.T) { + client := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("stored-credential recovery bypassed cooldown: %s", req.URL.Path) + return nil, nil + }) + meta := client.UserLogin.Metadata.(*UserLoginMetadata) + meta.Email, meta.Password = "user@example.com", "password" + connector := client.UserLogin.Bridge.Network.(*LineConnector) + connector.loginCooldown.block(string(client.UserLogin.User.MXID), time.Now().Add(15*time.Minute)) + err := client.tryLogin(context.Background()) + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("stored reconnect cooldown = %v", err) + } +} + +func TestStoredCredentialRecoveryStartsCooldownAfterProviderLimit(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, errors.New(`API error 429: {"code":10051,"data":{"name":"TalkException","reason":"Too many login attempts"}}`) + } + client := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("mocked login made an unexpected API request: %s", req.URL.Path) + return nil, nil + }) + meta := client.UserLogin.Metadata.(*UserLoginMetadata) + meta.Email, meta.Password = "user@example.com", "password" + connector := client.UserLogin.Bridge.Network.(*LineConnector) + if err := client.tryLogin(context.Background()); err == nil { + t.Fatal("rate-limited stored-credential recovery succeeded") + } + if connector.loginCooldown.remaining(string(client.UserLogin.User.MXID)) <= 0 { + t.Fatal("background reconnect did not activate cooldown") + } +} + func TestLoginCooldownExpires(t *testing.T) { var cooldown loginCooldown cooldown.block("@user:example.com", time.Now().Add(-time.Minute)) diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 4ca94d56..90e5ab97 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -187,7 +187,7 @@ func classifyEmailError(details loginErrorDetails, reason string) line.AuthError return line.AuthErrorTooManyAttempts case safeCategory == "too_many_login_attempts": return line.AuthErrorTooManyAttempts - case strings.EqualFold(strings.TrimSpace(reason), "retry later"): + case strings.EqualFold(strings.TrimSpace(reason), "retry later"), strings.EqualFold(strings.TrimSpace(reason), "RETRY_LATER"): return line.AuthErrorRetryLater case details.HTTPStatus == http.StatusTooManyRequests: return line.AuthErrorTooManyAttempts From 8811f31b25376bc247b0d87d4c61bcf85af07344 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:40:59 -0400 Subject: [PATCH 22/35] test: preserve V2 group encryption fallback coverage --- pkg/e2ee/manager_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go index 23f6aa44..181216f0 100644 --- a/pkg/e2ee/manager_test.go +++ b/pkg/e2ee/manager_test.go @@ -69,8 +69,8 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { if err != nil { t.Fatalf("spec-version-2 group encryption: %v", err) } - if version := ChunksVersion(groupChunks); version != 2 { - t.Fatalf("spec-version-2 group chunks version = %d, want 2", version) + if got, _, err := m.DecryptGroupMessage(&line.Message{Chunks: groupChunks, From: "u-sender", To: "u-receiver"}, "c-test"); err != nil || got != plaintext { + t.Fatalf("spec-version-2 group fallback decrypt = (%q, %v)", got, err) } got, groupKey, err := m.DecryptGroupMessage(msg, "c-test") if err != nil || got != plaintext || groupKey != myRaw { From d57862abee79ea7ec1c3000dca1b0caa53a49227 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 17:15:20 -0400 Subject: [PATCH 23/35] docs: trace chat sync and outbound authentication diagnostics Label existing connect, LINE fullSync and post-backfill synchronization triggers without changing policy. Document canonical DM resolution, retained retryable portal rows, outbound stage/account diagnostics, independent QR lifecycle findings and live-verification boundaries. --- pkg/connector/client.go | 1 + pkg/connector/sync.go | 3 +- readme/DEBUGGING.md | 152 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 readme/DEBUGGING.md diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 0a0a6648..f6998e2e 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -730,6 +730,7 @@ func (lc *LineClient) Connect(ctx context.Context) { // deliver messages. Otherwise an existing LINE group whose Matrix room // doesn't exist yet may be created by the first message, which makes the // sender's existing membership look like a fresh join. + lc.UserLogin.Bridge.Log.Info().Str("sync_trigger", "connect").Msg("Starting LINE conversation synchronization") lc.syncChatsNow(ctx) if ctx.Err() != nil { return diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index c74ad6c8..6c125f09 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -768,6 +768,7 @@ func (lc *LineClient) prefetchMessages(ctx context.Context) { // a hidden resync so a truncated recent-message window cannot leave a room // at an old membership or name. if backfilledSystemEvents.Load() && ctx.Err() == nil { + lc.UserLogin.Bridge.Log.Debug().Str("sync_trigger", "backfill_state_restore").Msg("Starting LINE conversation synchronization") lc.syncChatsNow(ctx) } } @@ -1651,7 +1652,7 @@ func (lc *LineClient) pollLoop(ctx context.Context) { // handle fullsync requests if eventType == "fullSync" { - lc.UserLogin.Bridge.Log.Info().Msg("Received fullSync request") + lc.UserLogin.Bridge.Log.Info().Str("sync_trigger", "line_full_sync").Msg("Received fullSync request") var fsPayload struct { NextRevision string `json:"nextRevision"` diff --git a/readme/DEBUGGING.md b/readme/DEBUGGING.md new file mode 100644 index 00000000..12089944 --- /dev/null +++ b/readme/DEBUGGING.md @@ -0,0 +1,152 @@ +# Direct chats, synchronization, outbound messages and QR login + +## Starting a direct chat + +In the bot management room: + +```text +search carol.id +start-chat carol.id +start-chat u0123456789abcdef0123456789abcdef +``` + +These are illustrative identifiers, not live test accounts. In another Matrix +room, use the configured command prefix (default `!line`), for example +`!line start-chat carol.id`. With several LINE logins, the framework also accepts +`start-chat `. + +Public LINE IDs use the existing `TalkService.findContactByUserid` RPC. The +bridge validates the returned user MID before creating a ghost or portal. User +MIDs are `u` followed by 32 hexadecimal digits; surrounding whitespace and input +case are normalized. A direct MID uses cached contact data or `getContactsV2`, +with the existing BuddyService fallback for official accounts. The bridge never +chooses a recipient by display name. `search` can still return display-name +matches when there is no exact ID match, but `start-chat` cannot use that fallback. + +Both commands share exact identifier resolution. A public-ID result already +contains the contact profile: it is reused for ghost/DM information rather than +re-fetching the same contact. A public ID is resolved afresh on each request so a +reassigned public ID cannot use a stale ID-to-MID alias cache. + +The former implementation treated every argument as a MID, created a portal +record immediately, and ignored the error returned by `GetChatInfo`. An argument +beginning with `c` was then routed to `getChats`; an empty result produced +`chat not found`. Framework room creation retried this missing chat-info lookup +and reported `Failed to create portal room`. + +The corrected response contains the canonical MID, login-scoped portal key and +complete DM information. bridgev2 retains responsibility for its locked portal +cache, database identity, room-creation event loop, membership checks and reuse. +Lookup-only requests do not create portal records. Failed remote lookups create +neither invalid portal records nor public-ID aliases. If Matrix room creation +fails later, bridgev2 retains one valid canonical row with no room ID for retry; +deleting it blindly would race another request. Leaving an existing room follows +bridgev2's existing orphaned-DM cleanup/recreation behavior. + +Historical invalid public-ID portal records are not migrated or deleted +automatically. Review such records separately before any production cleanup. + +## Why many conversations may appear + +Neither exact ID resolution nor its error path calls conversation synchronization. +The search-only display-name fallback enumerates contacts, not conversations, and +does not create portal rooms. Authentication recovery on these calls refreshes or +re-authenticates the session without invoking `Connect` or a broad chat sync. + +Existing legitimate broad synchronization sources are: + +* `LineClient.Connect`: group synchronization, DM synchronization and recent-message + prefetch on connection/startup. +* LINE SSE `fullSync`: refreshes block state, group/DM state and recent messages; + this can occur when server/client revision state requires reconciliation. +* Recent-message backfill containing system events: re-fetches current group state + after replay so historical membership/name events cannot leave stale state. + +New `sync_trigger` log fields identify `connect`, `line_full_sync`, and +`backfill_state_restore`. Existing portal mappings, message deduplication rows, +LINE revision state and persisted chat-deletion cutoffs affect what those paths +create or skip. No legitimate sync path has been disabled. The reported temporal +association with `start-chat` is not sufficient to establish a causal connection. +The earlier #196/#189 integrations changed media processing, not these sync paths. + +## Diagnosing an outbound message + +Trace one Matrix event ID rather than retrying across unrelated rooms/accounts: + +1. Matrix connector filtering and decryption. Relevant messages include + `Dropping ... event from user with no permission to send events`, + `Dropping unencrypted event as encryption is configured to be required`, + `Decrypting received event`, and `Failed to decrypt event`. +2. Portal lookup and `FindPreferredLogin`. A login-scoped portal uses its receiver + login. A different Matrix sender cannot use that login unless an explicitly + configured relay permits it. `Failed to get user login to handle Matrix event` + distinguishes this stage. Working bot commands do not establish that every + portal event has the same sender/login association or encryption session. +3. `Handling outbound Matrix message for LINE`: the connector has been reached. + Correlation fields include `matrix_event_id`, `matrix_sender`, `line_login_id` + and `line_recipient`. The recipient is the portal's canonical remote ID. +4. Message conversion and Letter Sealing selection/encryption, then the signed + `TalkService.sendMessage` RPC. `Finished LINE API send attempt` records + `success` and, on acknowledgement, `line_message_id`. Message bodies, access + tokens and QR approval data are not included in these new diagnostics. +5. The returned LINE message ID becomes the bridge message record. bridgev2 sends + the corresponding success/error status. An API acknowledgement is not proof of + recipient-side delivery/read status. + +Three defects were reproduced with mock responses and fixed: the send RPC did not +inherit the Matrix request context; HTTP-200 error wrappers lost TalkException +codes required by existing recovery logic; and a missing acknowledgement could +produce success with an empty ID or a nil dereference. Authentication/permission +rules, supported encryption paths and inbound conversion are unchanged. + +The actual Element delivery incident still needs a failing event's correlated +stage/error/status to identify its cause. Useful evidence is the event ID, sender, +portal receiver/login ownership, and the messages above, with tokens, message +bodies and approval codes removed. Do not infer a Synapse connectivity failure +from this report; command delivery and MID-based portal creation already work. + +## QR login: separate state machine + +Enable `network.qr_login: true` in the runtime configuration, then use: + +```text +login dev.highest.matrix.line.qr_login +relogin dev.highest.matrix.line.qr_login +cancel +``` + +Outside the management room, prefix these with `!line` (or the configured prefix). +The bridge requests a session and QR callback, adds the login public key, and +returns a display-and-wait step. bridgev2 renders the QR image for Matrix. After +LINE scan approval, a reusable certificate may satisfy verification; otherwise +the user must enter/approve a PIN on the primary device. Only then does token +exchange occur. The bridge verifies the token's profile/MID and required E2EE +keys before persisting a login, and checks cancellation/account identity during +finalization. + +Overlapping Start/Wait operations are now rejected instead of consuming another +waiter's polling result or blocking indefinitely. Startup deadline errors retain +`context.DeadlineExceeded`, rather than always becoming `context.Canceled`. +Cancel still interrupts the underlying attempt without waiting on the operation +guard. Expired polling is retried only within the existing server count/attempt +deadline; exhausted verification requires a fresh login attempt and QR scan. +There is no new speculative refresh endpoint or device-approval bypass. + +V3 refresh tokens are persisted by the existing finalizer. If refresh fails for a +passwordless QR account, reconnect must request a new QR approval rather than +inventing email/password credentials. Mock tests cover this and ensure that a +token response without a usable required keychain does not replace the stored +session. These lifecycle fixes do not establish the cause of an unspecified live +QR scanning/token-exchange failure. Successful live QR login and reconnection +have not been validated in this workspace. + +## Test boundaries + +New tests use mock LINE HTTP responses and isolated SQLite databases. They cover +shared resolution, invalid identities, lookup failures, cancellation/deadlines, +canonical portal-cache concurrency, existing-room reuse, creation failure/retry, +reopening after leaving, outbound ownership/routing/error propagation and QR state +transitions. The production PostgreSQL database and live Matrix/LINE accounts are +not used. Full live command concurrency, recipient delivery, and successful live +QR/session restoration require operator validation after deployment; mocked tests +are not a claim that those account-specific workflows have been verified. From 022678d7a5f43e1a1345f8ac94237ee3e97bdbf9 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Tue, 20 Oct 2026 17:15:28 -0400 Subject: [PATCH 24/35] diagnostics: identify synchronization triggers and document chat flows Label connection, LINE fullSync and historical-state reconciliation without changing sync behavior. Document canonical identity resolution, portal retry/reopening, outbound correlation and ownership checks, QR lifecycle fixes, mocked coverage and remaining live-verification requirements. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 732232fe..3c0f14f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge > need to log out of the bridge first as the bridge will try to automatically > log back in and invalidate the LINE Chrome session again. +For direct-chat identifiers, synchronization triggers, outbound-message diagnostics, +and QR login troubleshooting, see [the debugging guide](readme/DEBUGGING.md). + ## Features - [x] Messages From f7d87668e3275b0c7006fe50a52854eb720ebd83 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:50:04 -0400 Subject: [PATCH 25/35] test: bridge link preview images through Matrix media upload --- pkg/connector/handlers/handler.go | 2 + pkg/connector/handlers/text.go | 10 +++- pkg/connector/handlers/text_test.go | 78 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 pkg/connector/handlers/text_test.go diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 8d8542e3..36b89589 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -34,6 +34,8 @@ type Handler struct { // DownloadOBSResource overrides non-talk OBS downloads in tests. DownloadOBSResource func(ctx context.Context, client *line.Client, service, sid, oid string) ([]byte, error) + // DownloadPublicMedia overrides public link-preview downloads in tests. + DownloadPublicMedia func(ctx context.Context, client *line.Client, resource string) ([]byte, error) // DownloadAlbumPreview overrides album thumbnail downloads in tests. DownloadAlbumPreview func(ctx context.Context, client *line.Client, oid, chatID, albumID string) ([]byte, error) diff --git a/pkg/connector/handlers/text.go b/pkg/connector/handlers/text.go index 31afb1ba..44a067ac 100644 --- a/pkg/connector/handlers/text.go +++ b/pkg/connector/handlers/text.go @@ -41,7 +41,7 @@ func (h *Handler) ConvertText(ctx context.Context, portal *bridgev2.Portal, inte } if info.Image != "" && info.Obs.CDN != "" { if err := h.uploadPreviewImage(ctx, client, portal, intent, info.Obs.CDN+info.Image, preview); err != nil { - h.Log.Warn().Err(err).Str("url", requestURL).Msg("Failed to bridge link preview image") + h.Log.Warn().Msg("Failed to bridge link preview image") } } content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview} @@ -59,7 +59,13 @@ func (h *Handler) ConvertText(ctx context.Context, portal *bridgev2.Portal, inte } func (h *Handler) uploadPreviewImage(ctx context.Context, client *line.Client, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, imageURL string, preview *event.BeeperLinkPreview) error { - data, err := client.DownloadOBSPublicResource(ctx, imageURL) + var data []byte + var err error + if h.DownloadPublicMedia != nil { + data, err = h.DownloadPublicMedia(ctx, client, imageURL) + } else { + data, err = client.DownloadOBSPublicResource(ctx, imageURL) + } if err != nil { return err } diff --git a/pkg/connector/handlers/text_test.go b/pkg/connector/handlers/text_test.go new file mode 100644 index 00000000..c26174d8 --- /dev/null +++ b/pkg/connector/handlers/text_test.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/line" +) + +type linkPreviewRoundTripper func(*http.Request) (*http.Response, error) + +func (f linkPreviewRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestConvertTextUploadsOnlyImageLinkPreviews(t *testing.T) { + const pageInfo = `{"code":0,"result":{"domain":"example.com","title":"Title","summary":"Summary","image":"/preview.png","obs":{"cdn":"https://cdn.example/"}}}` + png := []byte("\x89PNG\r\n\x1a\npreview") + for _, tc := range []struct { + name string + image []byte + wantURL id.ContentURIString + wantType string + }{ + {"image uploaded", png, "mxc://example/", "image/png"}, + {"non-image omitted", []byte("not an image"), "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + client := line.NewClient("") + client.HTTPClient.Transport = linkPreviewRoundTripper(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/pageinfo/get") { + t.Fatalf("unexpected LINE request: %s", req.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(pageInfo))}, nil + }) + matrix := &postNotificationTestMatrix{} + uploadsBefore := matrix.uploadCount() + var downloadedURL string + h := &Handler{ + Log: zerolog.New(io.Discard), + NewClient: func() *line.Client { return client }, + DownloadPublicMedia: func(_ context.Context, _ *line.Client, resource string) ([]byte, error) { + downloadedURL = resource + return tc.image, nil + }, + } + portal := &bridgev2.Portal{Portal: &database.Portal{MXID: "!preview:example.com"}} + converted, err := h.ConvertText(context.Background(), portal, matrix, "See https://example.com/page", nil) + if err != nil || converted == nil || len(converted.Parts) != 1 { + t.Fatalf("ConvertText = %#v, %v", converted, err) + } + content := converted.Parts[0].Content + if content.Body != "See https://example.com/page" || len(content.BeeperLinkPreviews) != 1 { + t.Fatalf("text content = %#v", content) + } + preview := content.BeeperLinkPreviews[0] + if preview.ImageURL != tc.wantURL || preview.ImageType != tc.wantType { + t.Fatalf("preview image = url %q type %q", preview.ImageURL, preview.ImageType) + } + if downloadedURL != "https://cdn.example//preview.png" { + t.Fatalf("download URL = %q", downloadedURL) + } + wantUploads := uploadsBefore + if tc.wantURL != "" { + wantUploads++ + } + if matrix.uploadCount() != wantUploads { + t.Fatalf("upload count = %d, want %d", matrix.uploadCount(), wantUploads) + } + }) + } +} From e181e0dfb43f40d801766dcb9abd49eebf833338 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:53:11 -0400 Subject: [PATCH 26/35] test: restore opaque identity and recipient audit coverage --- pkg/connector/identity_audit_test.go | 77 +++++++++++++++++ pkg/connector/opaque_mid_test.go | 123 +++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 pkg/connector/identity_audit_test.go create mode 100644 pkg/connector/opaque_mid_test.go diff --git a/pkg/connector/identity_audit_test.go b/pkg/connector/identity_audit_test.go new file mode 100644 index 00000000..ec3c66c0 --- /dev/null +++ b/pkg/connector/identity_audit_test.go @@ -0,0 +1,77 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/beeper/line/pkg/line" +) + +func TestLookupToOutboundIdentityRepresentations(t *testing.T) { + for _, tc := range []struct{ name, returned, expected string }{ + {"legacy", resolutionMID, resolutionMID}, + {"legacy uppercase", strings.ToUpper(resolutionMID), resolutionMID}, + {"opaque", opaqueMID, opaqueMID}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + calls++ + switch { + case strings.HasSuffix(req.URL.Path, "/findContactByUserid"): + return resolutionResponse(`{"code":0,"data":{"mid":"` + tc.returned + `","displayName":"Fixture"}}`), nil + case strings.HasSuffix(req.URL.Path, "/sendMessage"): + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 2 { + t.Fatal("bad send args") + } + var sent line.Message + if err := json.Unmarshal(args[1], &sent); err != nil { + t.Fatal(err) + } + if sent.To != tc.expected || sent.From != resolutionOwnMID { + t.Fatalf("identity changed: %+v", sent) + } + return resolutionResponse(`{"code":0,"data":{"id":"fixture-message"}}`), nil + default: + t.Fatalf("unexpected sync/auth/API operation: %s", req.URL.Path) + return nil, nil + } + }) + ctx := context.Background() + resolved, err := lc.ResolveIdentifier(ctx, "fixture.id", true) + if err != nil { + t.Fatal(err) + } + msg := outboundTestMessage(t, lc) + msg.Portal, err = lc.UserLogin.Bridge.GetPortalByKey(ctx, resolved.Chat.PortalKey) + if err != nil { + t.Fatal(err) + } + response, err := lc.HandleMatrixMessage(ctx, msg) + if err != nil || response == nil { + t.Fatalf("send failed: %v", err) + } + if calls != 2 || !lc.hasAccessToken() || lc.isSessionInvalidated() { + t.Fatal("unexpected calls or invalidated session") + } + }) + } +} + +func TestUnsupportedLookupMIDDoesNotInvalidateLogin(t *testing.T) { + lc := newResolutionClient(t, func(*http.Request) (*http.Response, error) { + return resolutionResponse(`{"code":0,"data":{"mid":"U-unsupported-length"}}`), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), "fixture.id", true) + if err == nil || result != nil { + t.Fatal("unverified recipient accepted") + } + if !lc.hasAccessToken() || lc.isSessionInvalidated() { + t.Fatal("lookup validation invalidated login") + } + assertPortalCount(t, lc, 0) +} diff --git a/pkg/connector/opaque_mid_test.go b/pkg/connector/opaque_mid_test.go new file mode 100644 index 00000000..2852dd3d --- /dev/null +++ b/pkg/connector/opaque_mid_test.go @@ -0,0 +1,123 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2/provisionutil" + + "github.com/beeper/line/pkg/line" +) + +// Invented values matching the observed 44-byte U + URL-safe alphabet shape. +// Live evidence verified exact public lookup -> getContactsV2 identity roundtrip; +// these fixtures contain no production identifiers or profile data. +const opaqueMID = "U" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg" + +func TestOpaqueMIDPublicSearchAndPortalReuse(t *testing.T) { + calls := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + calls++ + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + t.Fatalf("unexpected fallback or sync: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + opaqueMID + `","displayName":"Fixture"}}`), nil + }) + ctx := context.Background() + var logs bytes.Buffer + lc.UserLogin.Bridge.Log = zerolog.New(&logs) + ctx = lc.UserLogin.Bridge.Log.WithContext(ctx) + search, err := lc.SearchUsers(ctx, "fixture.id") + if err != nil || len(search) != 1 || string(search[0].UserID) != opaqueMID { + t.Fatalf("search=%+v err=%v", search, err) + } + assertPortalCount(t, lc, 0) + first, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, opaqueMID, true) + if err != nil { + t.Fatalf("%v: %s", err, logs.String()) + } + second, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "fixture.id", true) + if err != nil || second.DMRoomID != first.DMRoomID || second.JustCreated { + t.Fatalf("portal identity changed: %+v %v", second, err) + } + assertPortalCount(t, lc, 1) + if calls != 2 || lc.UserLogin.Bridge.Matrix.(*resolutionMatrix).created != 1 { + t.Fatalf("unexpected calls=%d or duplicate room", calls) + } +} + +func TestOpaqueMIDExactIdentity(t *testing.T) { + for _, mismatch := range []bool{false, true} { + t.Run(map[bool]string{false: "unchanged", true: "case mismatch"}[mismatch], func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/getContactsV2") { + t.Fatalf("wrong endpoint: %s", req.URL.Path) + } + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || !strings.Contains(string(args[0]), opaqueMID) { + t.Fatalf("request changed opaque identity: %s", args) + } + mid := opaqueMID + if mismatch { + mid = "U" + strings.ToLower(mid[1:]) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + opaqueMID + `":{"contact":{"mid":"` + mid + `"}}}}}`), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), " "+opaqueMID+" ", true) + if mismatch { + if err == nil || result != nil { + t.Fatal("case-altered identity accepted") + } + } else if err != nil || result == nil || string(result.UserID) != opaqueMID { + t.Fatalf("result=%+v err=%v", result, err) + } + assertPortalCount(t, lc, 0) + }) + } +} + +func TestOpaqueMIDFallbackIdentity(t *testing.T) { + other := "U" + strings.ToLower(opaqueMID[1:]) + for _, tc := range []struct { + name, key, mid string + wantErr bool + }{ + {"valid", opaqueMID, opaqueMID, false}, + {"case mismatched body", opaqueMID, other, true}, + {"case mismatched key", other, opaqueMID, true}, + {"unsolicited case variant", other, other, true}, + } { + t.Run(tc.name, func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/findContactByUserid"): + return resolutionResponse(`{"code":0,"data":{}}`), nil + case strings.HasSuffix(req.URL.Path, "/getAllContactIds"): + return resolutionResponse(`{"code":0,"data":["` + opaqueMID + `"]}`), nil + case strings.HasSuffix(req.URL.Path, "/getContactsV2"): + body, _ := json.Marshal(map[string]any{"code": 0, "data": line.ContactsResponse{Contacts: map[string]line.ContactWrapper{tc.key: {Contact: line.Contact{Mid: tc.mid, DisplayName: "Fixture"}}}}}) + return resolutionResponse(string(body)), nil + default: + t.Fatalf("unexpected RPC: %s", req.URL.Path) + return nil, nil + } + }) + results, err := lc.SearchUsers(context.Background(), "Fixture") + if (err != nil) != tc.wantErr { + t.Fatalf("results=%+v err=%v", results, err) + } + if !tc.wantErr && (len(results) != 1 || string(results[0].UserID) != opaqueMID) { + t.Fatalf("opaque fallback identity changed: %+v", results) + } + if tc.wantErr && len(results) != 0 { + t.Fatal("invalid fallback accepted") + } + assertPortalCount(t, lc, 0) + }) + } +} From b68db47662fdc2a361021d36a25bd4f9c9942b85 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:53:21 -0400 Subject: [PATCH 27/35] test: verify stored group key spec version --- pkg/e2ee/manager_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go index 181216f0..d5355e37 100644 --- a/pkg/e2ee/manager_test.go +++ b/pkg/e2ee/manager_test.go @@ -55,8 +55,12 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { } m.groupKeys["c-test"] = map[int]int{myRaw: myKey} - m.latestGroupKey["c-test"] = myRaw - m.groupSpecVersion["c-test"] = 1 + if _, err := m.UnwrapGroupSharedKey("c-test", &line.E2EEGroupSharedKey{GroupKeyID: myRaw, SpecVersion: 1}); err != nil { + t.Fatalf("cache group spec version 1: %v", err) + } + if m.groupSpecVersion["c-test"] != 1 { + t.Fatalf("stored group spec version = %d, want 1", m.groupSpecVersion["c-test"]) + } groupChunks, err := m.EncryptGroupMessageRaw("c-test", "u-sender", 0, []byte(plaintext)) if err != nil { t.Fatalf("spec-version-1 group encryption: %v", err) @@ -64,7 +68,12 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { if version := ChunksVersion(groupChunks); version != 1 { t.Fatalf("spec-version-1 group chunks version = %d, want 1", version) } - m.groupSpecVersion["c-test"] = 2 + if _, err := m.UnwrapGroupSharedKey("c-test", &line.E2EEGroupSharedKey{GroupKeyID: myRaw, SpecVersion: 2}); err != nil { + t.Fatalf("cache group spec version 2: %v", err) + } + if m.groupSpecVersion["c-test"] != 2 { + t.Fatalf("rotated group spec version = %d, want 2", m.groupSpecVersion["c-test"]) + } groupChunks, err = m.EncryptGroupMessageRaw("c-test", "u-sender", 0, []byte(plaintext)) if err != nil { t.Fatalf("spec-version-2 group encryption: %v", err) From 47385433dddc8a10e63134dc099b55c938a926fd Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 00:55:55 -0400 Subject: [PATCH 28/35] ci: prevent fork builds from pushing Beeper images --- .github/workflows/deploy.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94cdd9a7..b918be6e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -78,6 +78,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to registry + if: github.event_name == 'push' && github.repository == 'beeper/line' uses: docker/login-action@v3 with: registry: ${{ secrets.CI_REGISTRY }} @@ -88,13 +89,14 @@ jobs: uses: docker/build-push-action@v5 with: context: . - cache-from: ${{ env.CI_REGISTRY_IMAGE }}:latest + cache-from: type=gha,scope=line-linux-amd64 + cache-to: type=gha,mode=max,scope=line-linux-amd64 pull: true file: Dockerfile tags: | - ${{ env.CI_REGISTRY_IMAGE }}:${{ github.sha }} - ${{ env.CI_REGISTRY_IMAGE }}:${{ github.sha }}-amd64 - push: true + ${{ github.event_name == 'push' && github.repository == 'beeper/line' && format('{0}:{1}', env.CI_REGISTRY_IMAGE, github.sha) || format('line-ci:{0}', github.sha) }} + ${{ github.event_name == 'push' && github.repository == 'beeper/line' && format('{0}:{1}-amd64', env.CI_REGISTRY_IMAGE, github.sha) || format('line-ci:{0}-amd64', github.sha) }} + push: ${{ github.event_name == 'push' && github.repository == 'beeper/line' }} deploy-docker: runs-on: ubuntu-latest @@ -102,7 +104,7 @@ jobs: - lint - build - build-docker - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.repository == 'beeper/line' steps: - name: Login to Beeper Docker registry uses: docker/login-action@v3 From 78752cb912e710da5e146149130300e600b197ec Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 01:01:58 -0400 Subject: [PATCH 29/35] fix: honor retry guidance across authentication transports --- pkg/connector/connector.go | 7 ++++ pkg/connector/login_cooldown.go | 6 ++++ pkg/connector/login_cooldown_test.go | 41 +++++++++++++++++++++++ pkg/connector/login_error_logging_test.go | 12 +++++++ pkg/connector/loginerrors.go | 17 ++++++++++ pkg/connector/resolve_identifier_test.go | 3 ++ pkg/line/auth_error.go | 2 +- pkg/line/auth_error_test.go | 1 + pkg/line/client.go | 4 +-- pkg/line/errors.go | 18 ++++++++++ pkg/line/qr_error_test.go | 11 ++++++ 11 files changed, 119 insertions(+), 3 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 241d3212..136eac0e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -547,6 +547,9 @@ func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { event.Str("provider_message", authErr.ProviderMessage) } event.Str("provider_message_status", authErr.ProviderMessageStatus) + if authErr.RetryAfter != nil { + event.Dur("retry_after", *authErr.RetryAfter) + } } event.Msg("LINE login attempt failed") } @@ -606,6 +609,9 @@ func (ll *LineEmailLogin) logQRFailure(err error, flow string) { Bool("user_action_required", ae.UserActionRequired). Bool("bridge_failure", ae.BridgeFailure). Bool("cooldown_triggered", ae.CooldownTriggered) + if ae.RetryAfter != nil { + event.Dur("retry_after", *ae.RetryAfter) + } } event.Msg("LINE QR login attempt failed") @@ -688,6 +694,7 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) ll.releaseLogin() return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) case <-done: + ll.Cancel() return nil, context.Canceled case <-ctx.Done(): ll.Cancel() diff --git a/pkg/connector/login_cooldown.go b/pkg/connector/login_cooldown.go index 3e039a64..ce9194c6 100644 --- a/pkg/connector/login_cooldown.go +++ b/pkg/connector/login_cooldown.go @@ -135,6 +135,9 @@ func classifyLoginRateLimit(err error) loginRateLimitKind { return loginRateLimitNone } } + if line.HTTPStatusCode(err) == http.StatusTooManyRequests { + return loginRateLimitLocked + } if detail, ok := line.QRLoginFailureDetail(err); ok { if detail.HTTPStatus == http.StatusTooManyRequests { @@ -199,6 +202,9 @@ func (ll *LineEmailLogin) noteLoginRateLimit(err error) { if authErr != nil && authErr.RetryAfter != nil && *authErr.RetryAfter > delay { delay = *authErr.RetryAfter } + if retryAfter := line.HTTPRetryAfter(err); retryAfter > delay && (line.HTTPStatusCode(err) == http.StatusTooManyRequests || line.HTTPStatusCode(err) >= http.StatusInternalServerError) { + delay = retryAfter + } if delay > 0 { ll.cooldown.block(string(ll.User.MXID), now.Add(delay)) } diff --git a/pkg/connector/login_cooldown_test.go b/pkg/connector/login_cooldown_test.go index db1d14db..a0b0cd4d 100644 --- a/pkg/connector/login_cooldown_test.go +++ b/pkg/connector/login_cooldown_test.go @@ -273,6 +273,26 @@ func TestStoredCredentialRecoveryStartsCooldownAfterProviderLimit(t *testing.T) } } +func TestStoredCredentialRecoveryHonorsHTTPRetryAfter(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + delay := 20 * time.Minute + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, &line.HTTPError{StatusCode: http.StatusTooManyRequests, RetryAfter: delay} + } + client := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("mocked login made an unexpected API request: %s", req.URL.Path) + return nil, nil + }) + meta := client.UserLogin.Metadata.(*UserLoginMetadata) + meta.Email, meta.Password = "user@example.com", "password" + connector := client.UserLogin.Bridge.Network.(*LineConnector) + _ = client.tryLogin(context.Background()) + if remaining := connector.loginCooldown.remaining(string(client.UserLogin.User.MXID)); remaining < delay-time.Second { + t.Fatalf("background cooldown=%s, want Retry-After %s", remaining, delay) + } +} + func TestLoginCooldownExpires(t *testing.T) { var cooldown loginCooldown cooldown.block("@user:example.com", time.Now().Add(-time.Minute)) @@ -306,6 +326,27 @@ func TestLoginClaimSerializesSameAccount(t *testing.T) { second.releaseLogin() } +func TestCanceledEmailVerificationReleasesLoginClaim(t *testing.T) { + var cooldown loginCooldown + user := &bridgev2.User{User: &database.User{MXID: "@cancelled:example.com"}} + login := &LineEmailLogin{User: user, cooldown: &cooldown, Verifier: "verification"} + ctx, cancel := context.WithCancel(context.Background()) + login.attemptCtx = ctx + login.pollResult = make(chan *line.LoginResult) + login.pollErr = make(chan error) + if err := login.claimLogin(); err != nil { + t.Fatal(err) + } + cancel() + if _, err := login.Wait(context.Background()); !errors.Is(err, context.Canceled) { + t.Fatalf("Wait after cancellation = %v", err) + } + if !cooldown.acquire(string(user.MXID)) { + t.Fatal("canceled verification retained the per-account login lock") + } + cooldown.release(string(user.MXID)) +} + func TestLoginCooldownUsesRetryAfter(t *testing.T) { var cooldown loginCooldown user := &bridgev2.User{User: &database.User{MXID: "@user:example.com"}} diff --git a/pkg/connector/login_error_logging_test.go b/pkg/connector/login_error_logging_test.go index c3f5a0d4..20238546 100644 --- a/pkg/connector/login_error_logging_test.go +++ b/pkg/connector/login_error_logging_test.go @@ -104,6 +104,18 @@ func TestSessionEstablishmentErrorKeepsStageAndSafeMessage(t *testing.T) { } } +func TestLoginTransportErrorsUseNetworkCategory(t *testing.T) { + wrapped := wrapLineLoginError(context.DeadlineExceeded) + var authErr *line.AuthError + if !errors.As(wrapped, &authErr) || authErr.Category != line.AuthErrorNetworkUnavailable || authErr.Stage != line.AuthStageEmailLogin { + t.Fatalf("transport error classification = %#v (%v)", authErr, wrapped) + } + var respErr bridgev2.RespError + if !errors.As(wrapped, &respErr) || !strings.Contains(respErr.Err, "reach LINE") { + t.Fatalf("network error is not actionable: %v", wrapped) + } +} + func TestLoginErrorSummaryDoesNotIncludeNonJSONResponseBody(t *testing.T) { err := errors.New("login failed: API error 502: upstream secret response") details := parseLoginErrorDetails(err) diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 90e5ab97..003a7b5a 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -95,13 +95,25 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { return nil } details := parseLoginErrorDetails(err) + if details.HTTPStatus == 0 { + details.HTTPStatus = line.HTTPStatusCode(err) + } reason := providerLoginMessage(details) safeReason := safeEmailLoginReason(reason) providerMessage, providerMessageStatus := line.SanitizeProviderMessage(reason) + retryAfter := line.HTTPRetryAfter(err) + var retryAfterPtr *time.Duration + if retryAfter > 0 { + retryAfterPtr = &retryAfter + } if safeReason == "" { safeReason = genericLoginFailureReason } cat := classifyEmailError(details, reason) + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) { + cat = line.AuthErrorNetworkUnavailable + } mapped := ErrLoginUnknown switch { @@ -113,6 +125,8 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) case cat == line.AuthErrorVerificationTemporarilyUnavailable: mapped = ErrLoginRejected.WithMessage("%s", safeReason) + case cat == line.AuthErrorNetworkUnavailable, cat == line.AuthErrorServiceUnavailable: + mapped = ErrLoginRejected.WithMessage("%s", line.UserMessageForCategory(cat)) case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: mapped = ErrLoginRejected case safeReason != "": @@ -131,6 +145,7 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { ProviderAlertStatus: "absent", ProviderMessage: providerMessage, ProviderMessageStatus: providerMessageStatus, + RetryAfter: retryAfterPtr, UserMessage: mapped.Err, RetryRecommended: isRetryableCategory(cat), Temporary: isTemporaryCategory(cat), @@ -199,6 +214,8 @@ func classifyEmailError(details loginErrorDetails, reason string) line.AuthError return line.AuthErrorVerificationTemporarilyUnavailable case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: return line.AuthErrorInvalidCredentials + case details.HTTPStatus >= http.StatusInternalServerError: + return line.AuthErrorServiceUnavailable default: return line.AuthErrorUnknown } diff --git a/pkg/connector/resolve_identifier_test.go b/pkg/connector/resolve_identifier_test.go index e357af4f..c07fef65 100644 --- a/pkg/connector/resolve_identifier_test.go +++ b/pkg/connector/resolve_identifier_test.go @@ -44,6 +44,9 @@ type resolutionMatrix struct { } func (*resolutionMatrix) Init(*bridgev2.Bridge) {} +func (m *resolutionMatrix) NewUserIntent(_ context.Context, userID id.UserID, accessToken string) (bridgev2.MatrixAPI, string, error) { + return &resolutionIntent{mxid: userID, matrix: m}, accessToken, nil +} func (m *resolutionMatrix) BotIntent() bridgev2.MatrixAPI { return &resolutionIntent{mxid: "@bot:example.com", matrix: m} } diff --git a/pkg/line/auth_error.go b/pkg/line/auth_error.go index f721797c..fc11fe95 100644 --- a/pkg/line/auth_error.go +++ b/pkg/line/auth_error.go @@ -202,7 +202,7 @@ func SanitizeProviderMessage(message string) (safe, status string) { message = providerJWTpattern.ReplaceAllString(message, "[redacted token]") message = providerOpaqueMIDPattern.ReplaceAllString(message, "[redacted LINE ID]") message = providerLongSecretPattern.ReplaceAllString(message, "[redacted token]") - if strings.ContainsAny(message, "{}") || strings.Contains(strings.ToLower(message), "bearer ") || strings.Contains(message, "https://") || strings.Contains(message, "http://") { + if strings.ContainsAny(message, "{}<>") || strings.Contains(strings.ToLower(message), "bearer ") || strings.Contains(message, "https://") || strings.Contains(message, "http://") { return "", "suppressed" } if (strings.Contains(strings.ToLower(message), "token ") || strings.Contains(strings.ToLower(message), "password ")) && !strings.Contains(message, "[redacted") && !isRecognizedProviderMessage(message) { diff --git a/pkg/line/auth_error_test.go b/pkg/line/auth_error_test.go index 5b9e064b..ca511d6e 100644 --- a/pkg/line/auth_error_test.go +++ b/pkg/line/auth_error_test.go @@ -12,6 +12,7 @@ func TestSanitizeProviderMessage(t *testing.T) { {"opaque token redacted", "Login failed: abcdefghijklmnopqrstuvwxyz0123456789, please retry.", "Login failed: [redacted token], please retry.", "partially_redacted"}, {"bounded safe provider text", "LINE could not complete this sign-in. Try again later.", "LINE could not complete this sign-in. Try again later.", "safe_unrecognized"}, {"structured response suppressed", `{"token":"private"}`, "", "suppressed"}, + {"markup suppressed", "unsafe", "", "suppressed"}, {"control sequence suppressed", "safe text\x1b[31mprivate", "", "suppressed"}, {"oversized message suppressed", string(make([]byte, 600)), "", "suppressed"}, {"short unknown suppressed", "no", "", "suppressed"}, diff --git a/pkg/line/client.go b/pkg/line/client.go index f69bfcb0..24135686 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -478,7 +478,7 @@ func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { - return nil, newHTTPError(resp.StatusCode, "API error %d: %s", resp.StatusCode, string(respBody)) + return nil, newHTTPResponseError(resp, "API error %d: %s", resp.StatusCode, string(respBody)) } return respBody, nil @@ -563,7 +563,7 @@ func (c *Client) postWithHMAC(fullURL string, body []byte) ([]byte, error) { if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) - return nil, newHTTPError(resp.StatusCode, "HTTP %d: %s", resp.StatusCode, string(respBody)) + return nil, newHTTPResponseError(resp, "HTTP %d: %s", resp.StatusCode, string(respBody)) } return io.ReadAll(resp.Body) diff --git a/pkg/line/errors.go b/pkg/line/errors.go index 6a80d976..518660ba 100644 --- a/pkg/line/errors.go +++ b/pkg/line/errors.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "time" ) var ( @@ -21,6 +22,7 @@ var ( // HTTPError is returned when a LINE endpoint answers with an unexpected HTTP status. type HTTPError struct { StatusCode int + RetryAfter time.Duration text string } @@ -28,6 +30,13 @@ func newHTTPError(statusCode int, format string, args ...any) *HTTPError { return &HTTPError{StatusCode: statusCode, text: fmt.Sprintf(format, args...)} } +// newHTTPResponseError retains bounded retry guidance from an HTTP response. +func newHTTPResponseError(resp *http.Response, format string, args ...any) *HTTPError { + err := newHTTPError(resp.StatusCode, format, args...) + err.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) + return err +} + func (e *HTTPError) Error() string { return e.text } @@ -42,6 +51,15 @@ func HTTPStatusCode(err error) int { return 0 } +// HTTPRetryAfter returns a parsed Retry-After duration carried by an HTTPError. +func HTTPRetryAfter(err error) time.Duration { + var httpErr *HTTPError + if errors.As(err, &httpErr) { + return httpErr.RetryAfter + } + return 0 +} + // IsTalkException returns true when LINE rejected a Talk API call with a // TalkException response. func IsTalkException(err error) bool { diff --git a/pkg/line/qr_error_test.go b/pkg/line/qr_error_test.go index 34d6625e..2735df07 100644 --- a/pkg/line/qr_error_test.go +++ b/pkg/line/qr_error_test.go @@ -81,6 +81,17 @@ func TestParseRetryAfter(t *testing.T) { } } +func TestHTTPErrorPreservesRetryAfter(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"45"}}, + } + err := newHTTPResponseError(response, "API error %d", response.StatusCode) + if got := HTTPRetryAfter(err); got != 45*time.Second { + t.Fatalf("HTTP Retry-After = %s, want 45s", got) + } +} + func TestQRAuthErrorStage(t *testing.T) { for method, want := range map[string]AuthErrorStage{ "createSession": AuthStageQRCreate, From 7c4303c10e888636656571304dd72cb38e4871f4 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 01:16:14 -0400 Subject: [PATCH 30/35] fix: preserve safe reconnect errors and release login claims --- pkg/connector/connector.go | 8 ++++++++ pkg/connector/login_error_logging_test.go | 22 ++++++++++++++++++++++ pkg/connector/loginerrors.go | 5 ++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 136eac0e..43cc29ea 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "strings" "sync" @@ -619,6 +620,13 @@ func (ll *LineEmailLogin) logQRFailure(err error, flow string) { func loginErrorReason(err error) string { details := parseLoginErrorDetails(err) + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) { + return line.UserMessageForCategory(line.AuthErrorNetworkUnavailable) + } + if details.HTTPStatus >= http.StatusInternalServerError { + return line.UserMessageForCategory(line.AuthErrorServiceUnavailable) + } return safeEmailLoginReason(providerLoginMessage(details)) } diff --git a/pkg/connector/login_error_logging_test.go b/pkg/connector/login_error_logging_test.go index 20238546..24c2ec9f 100644 --- a/pkg/connector/login_error_logging_test.go +++ b/pkg/connector/login_error_logging_test.go @@ -116,6 +116,28 @@ func TestLoginTransportErrorsUseNetworkCategory(t *testing.T) { } } +func TestReconnectErrorReasonKeepsNetworkAndProviderUnavailableMeaning(t *testing.T) { + if got := loginErrorReason(context.DeadlineExceeded); got != line.UserMessageForCategory(line.AuthErrorNetworkUnavailable) { + t.Fatalf("network reconnect reason = %q", got) + } + err := errors.New(`API error 503: {"code":10051,"data":{"name":"TalkException"}}`) + if got := loginErrorReason(err); got != line.UserMessageForCategory(line.AuthErrorServiceUnavailable) { + t.Fatalf("provider-unavailable reconnect reason = %q", got) + } +} + +func TestUnknownHTTP400LoginRejectionHasSafeFallback(t *testing.T) { + err := wrapLineLoginError(errors.New(`API error 400: {"code":10051,"data":{"name":"LoginException","code":987}}`)) + var authErr *line.AuthError + if !errors.As(err, &authErr) || authErr.Category != line.AuthErrorUnknown { + t.Fatalf("unknown rejection classification = %#v (%v)", authErr, err) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.Err != genericLoginFailureReason { + t.Fatalf("unknown rejection fallback = %v", err) + } +} + func TestLoginErrorSummaryDoesNotIncludeNonJSONResponseBody(t *testing.T) { err := errors.New("login failed: API error 502: upstream secret response") details := parseLoginErrorDetails(err) diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 003a7b5a..bbf2dd2c 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -106,9 +106,6 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { if retryAfter > 0 { retryAfterPtr = &retryAfter } - if safeReason == "" { - safeReason = genericLoginFailureReason - } cat := classifyEmailError(details, reason) var networkErr net.Error if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) { @@ -131,6 +128,8 @@ func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { mapped = ErrLoginRejected case safeReason != "": mapped = ErrLoginRejected.WithMessage("%s", safeReason) + case details.HTTPStatus >= http.StatusBadRequest && details.HTTPStatus < http.StatusInternalServerError: + mapped = ErrLoginRejected } ae := &line.AuthError{ From e50154bda1f0ac2632574048760d1bee18e5331a Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 08:07:28 -0400 Subject: [PATCH 31/35] build: disable unreliable linked-worktree VCS stamping --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 70699783..8143c5fd 100755 --- a/build.sh +++ b/build.sh @@ -6,4 +6,4 @@ else BUILD_TIME=$(date -Iseconds) fi GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=$BUILD_TIME' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags="$GO_LDFLAGS" "$@" ./cmd/matrix-line +go build -buildvcs=false -ldflags="$GO_LDFLAGS" "$@" ./cmd/matrix-line From f36c646b468719877c9fb4329f2cab18b652fbca Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 15:17:56 -0400 Subject: [PATCH 32/35] fix: restore friendly LINE login command aliases --- .github/workflows/deploy.yml | 3 + docs/FEATURE_PARITY.md | 43 +++++++++++ pkg/connector/connector.go | 1 + pkg/connector/login_command.go | 56 ++++++++++++++ pkg/connector/login_command_test.go | 114 ++++++++++++++++++++++++++++ readme/DEBUGGING.md | 8 ++ 6 files changed, 225 insertions(+) create mode 100644 docs/FEATURE_PARITY.md create mode 100644 pkg/connector/login_command.go create mode 100644 pkg/connector/login_command_test.go diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b918be6e..8ce04801 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,6 +170,9 @@ jobs: key: test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go', 'pkg/ltsm/data/*') }} restore-keys: | test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}- + - name: Verify LINE login aliases for release + if: matrix.mode == 'normal' + run: TEST_RUN='^TestRegisteredLineLoginCommandDispatch$' bash scripts/test.sh fast ./pkg/connector - name: Run validation run: bash scripts/test.sh "${{ matrix.mode }}" - name: Upload test reports diff --git a/docs/FEATURE_PARITY.md b/docs/FEATURE_PARITY.md new file mode 100644 index 00000000..6793fd0e --- /dev/null +++ b/docs/FEATURE_PARITY.md @@ -0,0 +1,43 @@ +# Feature parity with the previous development checkpoint + +Comparison baseline: checkpoint `910b4b3dceceb1dcd0e0b34e805ffc83d13d594f` +and known working tree `4eec1754b7916cbeed622529e5c9fb9ca129f6d2`, compared +with upstream-first release source `e50154bda1f0ac2632574048760d1bee18e5331a`. +The new source is a separate upstream integration history, so ancestry alone is +not evidence of parity. Feature and test paths were reviewed directly. + +| Historical feature | Source | Current implementation | Status / action | Coverage | +| --- | --- | --- | --- | --- | +| Friendly `login qr`, `login email`, `login help` | `4e9b184` | `pkg/connector/login_command.go`; registered in `LineConnector.Init` | Missing in rebuild because custom handler registration was omitted; restored | `pkg/connector/login_command_test.go` uses real framework processor | +| QR auth lifecycle and flow ID | upstream PR #271; `09a8685`, `6e333e5`, `4eec175` | `connector.go`, `login_qr.go`, `pkg/line/qr.go` | Present; alias dispatch is separate | `login_qr_test.go`, `qr_failure_test.go`, `qr_error_test.go` | +| Sanitized provider auth errors and cooldown | `6e333e5`, `596334a`, `4eec175`, `c6cf461`, `c1d2fad`, `7c4303c` | `loginerrors.go`, `login_cooldown.go`, `pkg/line/auth_error.go`, `pkg/line/errors.go` | Present and extended; do not roll back | `login_cooldown_test.go`, `login_error_logging_test.go`, `auth_error_test.go` | +| Public LINE ID resolution before portal creation | `695d6d9` | `userinfo.go`, `resolve_identifier.go` | Present | `resolve_identifier_test.go`, `identity_audit_test.go` | +| MID normalization and opaque case-sensitive IDs | `7615af2`, `05a1607`, `bde3468` | `pkg/line/user_mid.go`, `resolve_identifier.go`, `userinfo.go` | Present, with deliberate preservation of gateway identity case | `user_mid_test.go`, `opaque_mid_test.go`, `resolve_identifier_test.go` | +| Contact lookup validation / empty query safety | `160ac20`, `46a2e79` | `userinfo.go` | Present; validates returned contacts and avoids empty-query enumeration | `resolve_identifier_test.go` | +| Optional sanitized lookup-shape diagnostics | `65a2c02` | No current diagnostics code/config | Removed/superseded from rebuild. Not restored: diagnostics were opt-in but the old response parser/key allowlist is not present for current protocol shapes; requires a fresh privacy review and tests before reintroduction | Historical tests existed; no current coverage | +| Portal creation, canonical reuse, reopen and cancellation | `695d6d9`, `15ff9e4`, `971a828` | `resolve_identifier.go`, bridgev2 portal flow | Present | `resolve_identifier_test.go`, `identity_audit_test.go` | +| Outbound delivery, cancellation, provider error status | `c4ffe53`, `0597689`, `72c7a51` | `send_message.go`, `senderrors.go`, connector status mapping | Present | `send_message_test.go`, `outbound_pipeline_test.go` | +| Inbound handling and sync diagnostics | `022678d`, `d57862a` | `handle_message.go`, `sync.go` and bridge structured logs | Present; current upstream path retained | `handle_message_test.go`, `sync_test.go` | +| Media auth, image metadata, HEIC, audio, stickers, link previews | `0b79d0f`, `17ae6dc`, `e2670d4`, `6f7f92b`, `055d62e`, upstream PR #286 | `media.go`, `pkg/line/publicmedia.go`, handlers | Present in current rebuild | media and handler tests including `media_test.go`, `image_media_test.go`, `text_test.go` | +| Group E2EE spec version / crypto runtime behavior | `c5a432d`, `9be42f2`, `8811f31`, `b68db47` | `e2ee_keys.go`, `pkg/e2ee`, `pkg/ltsm` | Present/current implementation retained | `e2ee_keys_test.go`, `manager_test.go` | +| Test optimization and coverage gates | test optimization PR #1; `a8d80f2`, `1b0db13` | `scripts/test.sh`, `scripts/check_test_coverage.py`, coverage workflow | Present | normal/race/coverage/release modes and coverage gate | +| Build/release reproducibility and native packaging | `ffb62b5`, `e50154b` | `build.sh`, release scripts/docs | Present; exact release artifacts preserved | release validation workflow | + +## Intentional differences + +- The mautrix-go framework still accepts fully qualified login flow IDs; the + LINE aliases wrap, rather than replace, its standard `login` handler and keep + the framework's permissions, login limits, relogin, and command-state logic. +- QR login remains controlled by `network.qr_login`. The friendly help omits QR + when that option is disabled. +- Historical lookup diagnostics are not blindly restored. Current identity + validation and safe errors remain; any new diagnostics need current-schema + allowlisting and a privacy-focused review. +- No production deployment or database/session changes are part of this work. + +## Remaining gaps + +- The historical lookup-diagnostics feature needs a fresh implementation if + operational evidence shows it is still needed. +- Live Matrix/LINE behavior must be validated after an operator deploys the + prepared release candidate; this source change does not deploy it. diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 43cc29ea..4ff33066 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -44,6 +44,7 @@ func (lc *LineConnector) Init(bridge *bridgev2.Bridge) { // user doesn't already have one. bridge.Config.BridgeStatusNotices = "none" lc.br = bridge + lc.registerLoginCommand() } func (lc *LineConnector) Start(ctx context.Context) error { diff --git a/pkg/connector/login_command.go b/pkg/connector/login_command.go new file mode 100644 index 00000000..491a293e --- /dev/null +++ b/pkg/connector/login_command.go @@ -0,0 +1,56 @@ +package connector + +import ( + "fmt" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" +) + +func (lc *LineConnector) registerLoginCommand() { + if processor, ok := lc.br.Commands.(*commands.Processor); ok { + processor.AddHandler(lineLoginCommand()) + } +} + +func lineLoginCommand() *commands.FullHandler { + // Copy the framework handler to retain its permission requirements without + // modifying the shared handler. Only method selection is LINE-specific. + handler := *commands.CommandLogin + handler.Help.Args = "[qr|email|help]" + handler.Func = func(ce *commands.Event) { + flows := ce.Bridge.Network.GetLoginFlows() + var help strings.Builder + help.WriteString("Choose a LINE login method:\n\n") + for _, flow := range flows { + method := flow.ID + switch flow.ID { + case LoginFlowIDQR: + method = "qr" + case LoginFlowIDEmail: + method = "email" + } + _, _ = fmt.Fprintf(&help, "* `$cmdprefix login %s` — %s\n", method, flow.Description) + } + if len(ce.Args) == 0 || strings.EqualFold(ce.Args[0], "help") { + ce.Reply(help.String()) + return + } + method := strings.ToLower(ce.Args[0]) + switch method { + case "qr": + method = LoginFlowIDQR + case "email": + method = LoginFlowIDEmail + } + for _, flow := range flows { + if strings.EqualFold(flow.ID, method) { + ce.Args = append([]string{flow.ID}, ce.Args[1:]...) + commands.CommandLogin.Run(ce) + return + } + } + ce.Reply("Unknown or unavailable LINE login method.\n\n" + help.String()) + } + return &handler +} diff --git a/pkg/connector/login_command_test.go b/pkg/connector/login_command_test.go new file mode 100644 index 00000000..f73bcb27 --- /dev/null +++ b/pkg/connector/login_command_test.go @@ -0,0 +1,114 @@ +package connector + +import ( + "context" + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type loginCommandIntent struct { + bridgev2.MatrixAPI + replies []string +} + +func (i *loginCommandIntent) GetMXID() id.UserID { return "@bot:example.com" } +func (*loginCommandIntent) IsDoublePuppet() bool { return false } +func (i *loginCommandIntent) SendMessage(_ context.Context, _ id.RoomID, _ event.Type, content *event.Content, _ *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { + i.replies = append(i.replies, content.AsMessage().Body) + return &mautrix.RespSendEvent{}, nil +} + +type loginCommandMatrix struct { + bridgev2.MatrixConnector + bot *loginCommandIntent +} + +func (*loginCommandMatrix) Init(*bridgev2.Bridge) {} +func (m *loginCommandMatrix) BotIntent() bridgev2.MatrixAPI { return m.bot } +func (*loginCommandMatrix) SendMessageStatus(context.Context, *bridgev2.MessageStatus, *bridgev2.MessageStatusEventInfo) { +} + +type loginCommandNetwork struct { + LineConnector + chosen string +} + +func (n *loginCommandNetwork) CreateLogin(_ context.Context, _ *bridgev2.User, flow string) (bridgev2.LoginProcess, error) { + n.chosen = flow + return nil, errors.New("test authentication dispatch") +} + +// This test goes through mautrix-go's actual command processor and the handler +// installed by LineConnector.Init, not just the handler function in isolation. +func TestRegisteredLineLoginCommandDispatch(t *testing.T) { + ctx := context.Background() + db, err := dbutil.NewWithDialect("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "bridge.db")), "sqlite3") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + network := &loginCommandNetwork{LineConnector: LineConnector{Config: Config{QRLogin: true}}} + bot := &loginCommandIntent{} + matrix := &loginCommandMatrix{bot: bot} + br := bridgev2.NewBridge("test", db, zerolog.New(io.Discard), &bridgeconfig.BridgeConfig{CommandPrefix: "!line"}, matrix, network, func(br *bridgev2.Bridge) bridgev2.CommandProcessor { + return commands.NewProcessor(br) + }) + if network.br != br { + t.Fatalf("connector Init was not called: connector bridge %p, expected %p", network.br, br) + } + if _, ok := br.Commands.(*commands.Processor); !ok { + t.Fatalf("commands processor has unexpected type %T", br.Commands) + } + if !commands.CommandLogin.RequiresLoginPermission { + t.Fatal("wrapped login command lost framework login permission requirement") + } + if !strings.Contains(lineLoginCommand().Help.Args, "qr|email|help") { + t.Fatal("registered login command help does not list friendly methods") + } + if err = br.DB.Upgrade(ctx); err != nil { + t.Fatal(err) + } + user := &bridgev2.User{User: &database.User{MXID: "@owner:example.com", ManagementRoom: "!management:example.com"}, Bridge: br, Permissions: bridgeconfig.Permissions{Login: true, MaxLogins: 5}} + for _, tc := range []struct { + name, command, args, wantFlow, wantReply string + qrEnabled bool + }{ + {"default help", "login", "", "", "login qr", true}, + {"explicit help", "login", "help", "", "login email", true}, + {"qr alias", "login", "qr", LoginFlowIDQR, "Failed to prepare login process", true}, + {"case-insensitive qr alias", "login", "QR", LoginFlowIDQR, "Failed to prepare login process", true}, + {"email alias", "login", "email", LoginFlowIDEmail, "Failed to prepare login process", true}, + {"case-insensitive email alias", "login", "EMAIL", LoginFlowIDEmail, "Failed to prepare login process", true}, + {"qr flow ID", "login", LoginFlowIDQR, LoginFlowIDQR, "Failed to prepare login process", true}, + {"email flow ID", "login", LoginFlowIDEmail, LoginFlowIDEmail, "Failed to prepare login process", true}, + {"unknown method", "login", "invalid", "", "Unknown or unavailable LINE login method", true}, + {"disabled qr", "login", "qr", "", "Unknown or unavailable LINE login method", false}, + } { + t.Run(tc.name, func(t *testing.T) { + network.chosen = "" + network.Config.QRLogin = tc.qrEnabled + bot.replies = nil + processor := br.Commands.(*commands.Processor) + processor.Handle(ctx, user.ManagementRoom, "$command", user, tc.command+" "+tc.args, "") + if network.chosen != tc.wantFlow { + t.Fatalf("selected flow %q, want %q (replies %q)", network.chosen, tc.wantFlow, bot.replies) + } + if len(bot.replies) == 0 || !strings.Contains(bot.replies[len(bot.replies)-1], tc.wantReply) { + t.Fatalf("replies %q do not contain %q", bot.replies, tc.wantReply) + } + }) + } +} diff --git a/readme/DEBUGGING.md b/readme/DEBUGGING.md index 12089944..9cecab12 100644 --- a/readme/DEBUGGING.md +++ b/readme/DEBUGGING.md @@ -1,5 +1,13 @@ # Direct chats, synchronization, outbound messages and QR login +## Choosing a login method + +In the bot management room, use `login` or `login help` to list available +methods. `login email` starts email/password login; `login qr` starts QR login +when `network.qr_login` is enabled. Method names are case-insensitive. In other +rooms, prefix the command (for example, `!line login qr`). Fully qualified +mautrix-go flow IDs remain accepted for provisioning and existing clients. + ## Starting a direct chat In the bot management room: From 18eb2a3cca6d46735ccd0508fce7bfdc4c0d8d46 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 16:54:07 -0400 Subject: [PATCH 33/35] docs: record upstream integration status --- docs/UPSTREAM_INTEGRATION_STATUS.md | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/UPSTREAM_INTEGRATION_STATUS.md diff --git a/docs/UPSTREAM_INTEGRATION_STATUS.md b/docs/UPSTREAM_INTEGRATION_STATUS.md new file mode 100644 index 00000000..32ce9b87 --- /dev/null +++ b/docs/UPSTREAM_INTEGRATION_STATUS.md @@ -0,0 +1,66 @@ +# Upstream integration status + +## Verified sources + +- Current upstream baseline fetched from `origin/main`: `45a48b9b3b57330d99d326d443f9a9fc8725a48f`. +- Immutable working checkpoint: `f36c646b468719877c9fb4329f2cab18b652fbca`, branch + `checkpoint/line-feature-complete-20261029`, tag + `checkpoint/line-feature-complete-20261029-f36c646b`. +- Upstream integration branch `upstream/line-stabilization-20261029` merges the + checkpoint with the current upstream baseline. It retains upstream #296's + bounded/cancellable search and avatar request improvements alongside the + checkpoint's exact ID validation, contact-result checks, and portal reuse. +- Working release v1.2.0+dev.f36c646b is still the byte-identical binary running + on `element-chat`; no service was changed in this integration task. + +## Historical PR relationships + +- `beeper/line#271` is still open (head `meramsey/line:integration/linux-build-20261020`, + SHA `587164f7ced99bc5765ac7df4eafc11314e6d602`). Its eight patches are present + in the checkpoint by patch equivalence. This integration is intended to + supersede #271 because it contains those features plus follow-up authentication, + identity, contact validation, media, CI, and release corrections. #271 remains + open and untouched; do not merge both versions without comparing their diffs. +- `meramsey/line#1` remains open against the fork's integration branch (head + `a7b5764a6a4c0d45d10814fd101c21eca70199b9`). Relevant bounded test modes, + deterministic concurrency fixtures, native heap test support, coverage gates, + and CI caching are adapted in this integration. Historical benchmark numbers + in its old performance report are intentionally not copied as they are + environment-specific. +- Upstream commits already incorporated include latest #296 (`45a48b9`), which + bounds search results and honors cancellation. The integration retains this + behavior rather than reverting it. + +## Feature map + +| Area | Implementation | Regression coverage | Status | +| --- | --- | --- | --- | +| Friendly login aliases and QR selection | `pkg/connector/login_command.go`, `connector.go` | `login_command_test.go` through framework Processor | In new PR | +| QR lifecycle, auth stages, sanitized provider errors, 429/Retry-After and per-login cooldown | `login_qr.go`, `loginerrors.go`, `login_cooldown.go`, `pkg/line/{qr,auth_error,errors}.go` | QR, error, and cooldown suites | In new PR | +| Public ID, canonical/opaque MIDs, validation, portal reuse/reopen | `resolve_identifier.go`, `userinfo.go`, `pkg/line/user_mid.go` | resolver, opaque MID, and portal tests | In new PR; preserves upstream #296 limits/cancellation | +| Send cancellation/acknowledgements, inbound media auth and metadata | `send_message.go`, `media.go`, handlers | outbound, media, image metadata tests | In new PR | +| CI caches, test modes, coverage gate, reproducible build | `.github/workflows/deploy.yml`, `scripts/`, `build.sh` | release validation | In new PR | +| Optional lookup-shape diagnostics | No current implementation | No current test | Intentionally deferred pending a privacy/schema review; current safe validation and errors remain | + +## Validation + +On the integration branch (source merge `07badf92460e5ae744b90d1c0c0a46cc30396218`, +plus this status-documentation commit): + +- Normal: 512 passing test/subtest events, one skip, zero failures. +- Race: 512 passing test/subtest events, one skip, zero failures. +- Coverage: 512 passing test/subtest events, one skip, zero failures; all package floors passed. +- Release: all checks passed, including module verification, static analysis, build, and the three suites above. +- Targeted login, search/identity, portal, QR, auth error, and cooldown tests passed. + +## Publication state and remaining work + +- Checkpoint branch/tag are published to the `meramsey/line` fork. +- The upstream integration branch is prepared for a replacement PR targeting + `beeper/line:main`. GitHub CLI has no authenticated session in this environment, + so PR creation requires interactive authentication. The pushed branch's + compare URL is `https://github.com/beeper/line/compare/main...meramsey:line:upstream/line-stabilization-20261029`. +- No feature claim should imply upstream coverage until the replacement PR is + reviewed/merged. Do not close PR #271 or modify PR #1 automatically. +- Live QR/provider behavior and inbound puppet E2EE sender/device warnings remain + operator-verification items. Matrix encryption was not altered. From 81f502150b88e681d2703239df608b1b38ccc2d6 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Thu, 29 Oct 2026 20:39:33 -0400 Subject: [PATCH 34/35] docs: clarify existing upstream PR patch coverage --- docs/UPSTREAM_INTEGRATION_STATUS.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/UPSTREAM_INTEGRATION_STATUS.md b/docs/UPSTREAM_INTEGRATION_STATUS.md index 32ce9b87..84ebcc02 100644 --- a/docs/UPSTREAM_INTEGRATION_STATUS.md +++ b/docs/UPSTREAM_INTEGRATION_STATUS.md @@ -16,17 +16,16 @@ ## Historical PR relationships - `beeper/line#271` is still open (head `meramsey/line:integration/linux-build-20261020`, - SHA `587164f7ced99bc5765ac7df4eafc11314e6d602`). Its eight patches are present + SHA `587164f7ced99bc5765ac7df4eafc11314e6d602`). Its nine commits are present in the checkpoint by patch equivalence. This integration is intended to supersede #271 because it contains those features plus follow-up authentication, identity, contact validation, media, CI, and release corrections. #271 remains open and untouched; do not merge both versions without comparing their diffs. -- `meramsey/line#1` remains open against the fork's integration branch (head - `a7b5764a6a4c0d45d10814fd101c21eca70199b9`). Relevant bounded test modes, - deterministic concurrency fixtures, native heap test support, coverage gates, - and CI caching are adapted in this integration. Historical benchmark numbers - in its old performance report are intentionally not copied as they are - environment-specific. +- `meramsey/line#1` remains open against the fork's integration branch (6 commits, + head `a7b5764a6a4c0d45d10814fd101c21eca70199b9`). Two patches are identical by + patch ID; bounded test modes, measurements, coverage gates, and CI caching were + adapted in later integration commits. Historical benchmark numbers in its old + performance report are intentionally not copied as they are environment-specific. - Upstream commits already incorporated include latest #296 (`45a48b9`), which bounds search results and honors cancellation. The integration retains this behavior rather than reverting it. From 4971ee335acbaa10ab4feb1346839fdec3049c20 Mon Sep 17 00:00:00 2001 From: Michael Ramsey Date: Mon, 2 Nov 2026 23:21:37 -0500 Subject: [PATCH 35/35] docs: add PR 297 reviewer comparison, replay matrix and risk assessment --- docs/PR297_BEFORE_AFTER.md | 57 ++++++++++++ docs/PR297_BLAST_RADIUS.md | 99 ++++++++++++++++++++ docs/PR297_TEST_MATRIX.md | 139 ++++++++++++++++++++++++++++ docs/UPSTREAM_INTEGRATION_STATUS.md | 14 +++ 4 files changed, 309 insertions(+) create mode 100644 docs/PR297_BEFORE_AFTER.md create mode 100644 docs/PR297_BLAST_RADIUS.md create mode 100644 docs/PR297_TEST_MATRIX.md diff --git a/docs/PR297_BEFORE_AFTER.md b/docs/PR297_BEFORE_AFTER.md new file mode 100644 index 00000000..817eb5f9 --- /dev/null +++ b/docs/PR297_BEFORE_AFTER.md @@ -0,0 +1,57 @@ +# PR #297: before and after + +Review alongside the [replay matrix](PR297_TEST_MATRIX.md) and +[blast-radius assessment](PR297_BLAST_RADIUS.md). + +## Comparison boundary + +“Before” below means upstream `45a48b9b3b57330d99d326d443f9a9fc8725a48f`, +the last upstream commit integrated into this branch. “After” is PR source +`81f502150b88e681d2703239df608b1b38ccc2d6` plus documentation-only updates. +It is **not** a claim that today's upstream lacks every improvement below. +On 2026-11-02, upstream was `10e0a5fdd18440d4b5c23b5dd0f9cc75cc7dd3fe`; +GitHub reports a conflict. A read-only `git merge-tree` check located the +content conflict in `pkg/connector/client.go`. Upstream #294 (transient token +recovery), #298 (persistent E2EE reconnect prompt), #299 (avatar concurrency), +#295 (send/media error mapping), #302 (media size handling), and #303 (stickers) +must be preserved and revalidated during a future integration/split. This +documentation update neither resolves that conflict nor changes runtime code. + +## Behavioral matrix + +Test names below link to tests in this branch. These are local fixture/mock +tests, not evidence of successful live authentication or message delivery. + +| Component / feature | Before PR #297 | After PR #297 | Observable behavior | Test / verification reference | Risk / compatibility impact | +| --- | --- | --- | --- | --- | --- | +| `login`, `login help` | Framework lists full flow IDs; `help` is treated as a flow ID | LINE handler provides friendly method usage | Help without starting LINE auth | [login command test](../pkg/connector/login_command_test.go), `TestRegisteredLineLoginCommandDispatch` | Changes default command UX; framework permissions retained | +| `login qr`, `login email`, full IDs, QR disabled | Short methods invalid; full IDs already supported; QR already configurable | Case-insensitive aliases select existing flows; full IDs retained; disabled QR omitted/rejected | One selected flow or useful unavailable-method guidance | Same test; [handler](../pkg/connector/login_command.go), registered in `LineConnector.Init` | Flow selection only; not a replacement QR protocol; no new config key | +| QR creation, scan/PIN verification, token issue | QR implementation already exists; overlapping Start/Wait calls can mutate/consume state; deadline errors can become cancellation | Overlap rejected, deadline/cancel retained; token checked before session finalization | Stages complete in order; malformed/incomplete results fail rather than silently persisting | [QR lifecycle tests](../pkg/connector/login_qr_test.go): `TestQRConcurrentWaitIsRejected`, `TestQRApprovalPINAndTokenValidation`, `TestQRExpiryAndCancellation`, `TestQRStartPreservesDeadline`; [wire contract](../pkg/line/qr_contract_test.go) | High-risk login lifecycle path; server compatibility needs live confirmation | +| QR rejection / auth stages / session restoration | QR failures expose limited status/code detail; generic session invalidation can imply another client | Structured QR-create/verify/token and email/session stages; neutral invalidation wording; passwordless reconnect still requires approval | Distinguish provider rejection, temporary verification, network failure and session establishment | [QR error tests](../pkg/line/qr_error_test.go), `TestQRAuthErrorStage`; [logging tests](../pkg/connector/login_error_logging_test.go), `TestSessionEstablishmentErrorKeepsStageAndSafeMessage`; `TestQRPasswordlessReconnectNeedsApproval` | Does not bypass mobile approval, fix restrictions, or guarantee session restoration | +| Provider messages | Email reason/error text can be propagated too broadly; QR detail too sparse | Classified errors with bounded selectively redacted safe messages; unsafe structured/URL/control/secret content suppressed | Useful safe wording where allowed; generic category fallback otherwise | [sanitizer](../pkg/line/auth_error.go), [tests](../pkg/line/auth_error_test.go), `TestSanitizeProviderMessage`; [rejection tests](../pkg/connector/qr_failure_test.go) | Redaction is conservative and may suppress useful text; not a guarantee that all upstream raw errors/logs are sanitized | +| HTTP 429 / RETRY_LATER / verification unavailable | No shared connector login cooldown | Defaults: 15 / 5 / 10 minutes respectively; Retry-After can extend wait | Immediate re-attempt blocked locally; cooldown is not an automatic delayed retry | [cooldown tests](../pkg/connector/login_cooldown_test.go): `TestQRStartupRateLimitMapsAndStartsCooldown`, `TestVerificationTemporarilyUnavailableBlocksRetry`, `TestLoginRateLimitClassification`; [HTTP tests](../pkg/line/qr_error_test.go): `TestParseRetryAfter`, `TestHTTPErrorPreservesRetryAfter` | High account-safety impact; bridge wait does not establish LINE's actual restriction duration | +| Login serialization / stored-credential recovery | Interactive and stored-credential paths lack shared claims/cooldown enforcement | Claims and waits shared by Matrix user ID; recovery checks them before credential request; release on cancel/error | Concurrent login rejected; stored credential path does not bypass active wait | `TestLoginClaimSerializesSameAccount`, `TestStoredCredentialRecoveryRespectsActiveCooldown`, `TestStoredCredentialRecoveryHonorsHTTPRetryAfter`, `TestCanceledEmailVerificationReleasesLoginClaim` | In-memory, lost at restart; two Matrix users for one LINE account are not globally coordinated; multiple logins for one Matrix user share the gate | +| `start-chat ` / lookup-only | Treats input as MID; creates portal record before lookup and ignores chat-info error | Public ID resolves via `findContactByUserid`; validates before portal persistence; lookup-only does not create chat | Correct recipient or clear failure instead of retrying a fabricated portal | [resolver tests](../pkg/connector/resolve_identifier_test.go): `TestResolveIdentifierPublicMIDAndSearch`, `TestResolveIdentifierFailuresDoNotPersistPortals`; [resolver](../pkg/connector/resolve_identifier.go) | High identity/routing impact; depends on LINE ID-lookup permission; no display-name choice for start-chat | +| Internal MIDs / opaque gateway IDs | No shared validated normalization for returned IDs | Legacy 33-character hexadecimal MID canonicalized; 44-character `U…` gateway ID preserves case | Public ID and internal ID reach the same validated identity | [MID tests](../pkg/line/user_mid_test.go), [opaque tests](../pkg/connector/opaque_mid_test.go), `TestResolveIdentifierPreservesOpaqueMIDCase`; [identity audit](../pkg/connector/identity_audit_test.go) | Not every value beginning with `M`/`U` is supported; unknown shapes rejected, not guessed | +| Portal reuse / reopen / lookup validation | Portal key can be the unresolved public string; lookup errors discarded | Canonical recipient + login receiver key; framework reuses/reopens room; mismatched/ambiguous responses rejected | Repeat start-chat reuses DM; failed lookup does not create new row | `TestStartChatCreatesReusesAndRetriesRoom`, `TestResolveIdentifierConcurrentCanonicalPortalReuse`, `TestResolveIdentifierDeadlineAndMismatchedMID`, `TestSearchUsersRejectsUnrequestedContactMID` | No migration of old incorrectly keyed portals; malformed provider data may now fail closed | +| Search / sync requests | Exact result may still fall back to contact enumeration; search errors hidden | Exact result returns immediately; empty search no requests; errors returned; public ID resolved afresh, contact data cached by MID | Less unnecessary contact lookup for exact ID; no added bulk-sync policy | `TestSearchUsersEmptyQueryDoesNotEnumerateContacts`, resolver tests; [userinfo](../pkg/connector/userinfo.go), [sync diff](../pkg/connector/sync.go) | Fallback search still lists contacts/batches (cap 20 from upstream #296); connect/fullSync reconciliation still exists | +| Outbound cancellation / error / acknowledgement | Matrix request context not explicitly passed to send RPC; missing message ID can look successful; provider error codes lost | Context-aware send; require nonempty LINE ack ID; preserve provider error structure for existing recovery | Failed/empty ack not reported as success; cancellation reaches request | [outbound tests](../pkg/connector/outbound_pipeline_test.go): `TestOutboundOwnerAndCanonicalRecipient`, `TestOutboundRequestCancellation`; [ack tests](../pkg/line/send_message_response_test.go) | High-risk delivery path; ack is server acceptance, not read/delivery proof. Existing reqSeq/retry machinery retained; no new exactly-once guarantee | +| Encrypted inbound media / direct-media handler | Trailing MAC stripped without verification | Verify HMAC before AES-CTR plaintext; video uses chunk-hash MAC input; wrong key/mode/corruption rejected | Corrupt/unsupported encrypted content fails rather than displaying unauthenticated bytes | [media tests](../pkg/connector/media_hmac_test.go), `TestMediaHMAC`; [handler routing](../pkg/connector/handlers/handler.go) | Security behavior intentionally stricter; affects images, files, audio, video, thumbnails; requires real client-format tests | +| Inbound image metadata | Always image.jpg / image/jpeg without new dimensions info | Detect MIME/extension/byte size; dimensions where decoder supports them; bounded AVIF/HEIC/HEIF brands; JPEG fallback | Correct Matrix metadata when recognized; dimensions may be absent | [metadata tests](../pkg/connector/handlers/image_media_test.go), `TestLineImageMediaInfo`, `TestLineImageBrands`; [image handler](../pkg/connector/handlers/image.go) | Metadata detection is not universal format decoding/transcoding support | +| Link preview media | Base already uploads preview images to Matrix | Adds injection-based coverage; stops logging failed preview URL/raw error | Existing upload behavior preserved; less sensitive log context | [text tests](../pkg/connector/handlers/text_test.go), `TestConvertTextUploadsOnlyImageLinkPreviews` | Do not credit this PR with upstream's earlier preview upload feature; HEIC/audio/sticker improvements already in integrated upstream retained | +| Test performance | Expensive redundant crypto/cache fixtures and timing sleeps | Real native heap pressure fixture; bounded cache rotations; deterministic concurrency; vector output in temp dirs | Faster isolated fixtures; stress/recovery assertions retained | [runner fixtures](../pkg/runner_secret_test.go), [memory tests](../pkg/ltsm/memory_test.go), [album tests](../pkg/connector/handlers/post_notification_test.go) | Exported native allocation wrapper added for fixtures; generated crypto not edited | +| Validation / coverage / CI cache | No new test-mode scripts/package floors | Fast/normal/race/coverage/release modes; per-package gates; CI normal/race/coverage; instrumentation-keyed cache; fork Docker push/deploy guards | Local replay plus bounded CI jobs without publishing fork images to Beeper | [script](../scripts/test.sh), [checker](../scripts/check_test_coverage.py), [workflow](../.github/workflows/deploy.yml) | Cache path shared but keys vary by mode/toolchain/ABI; release is a local mode, not a fourth CI matrix entry. No GitHub checks currently reported | +| Reproducible builds | Wall-clock timestamp; build flags after package; automatic VCS stamp | SOURCE_DATE_EPOCH supported; flags before package; explicit commit/tag and `-buildvcs=false` | Two builds in same controlled environment reproduce | [build script](../build.sh); checkpoint evidence in replay matrix | Not a universal cross-path/toolchain reproducibility claim; tag presence changes bytes | + +## Attribution and scope + +This consolidates [#271](https://github.com/beeper/line/pull/271): all nine +commits are represented by patch ID in this history. It adapts compatible work +from [fork PR #1](https://github.com/meramsey/line/pull/1) (two identical patches, +later adaptations of tooling; old benchmark report intentionally excluded). +Media authentication and image metadata retain attribution to Adrien Vermersch's +[#196](https://github.com/beeper/line/pull/196) and +[#189](https://github.com/beeper/line/pull/189). +Neither existing PR is closed/retargeted. They overlap; do not merge independently +without comparison. Proposed smaller review units and dependencies are in the +[blast-radius document](PR297_BLAST_RADIUS.md#suggested-smaller-review-units). diff --git a/docs/PR297_BLAST_RADIUS.md b/docs/PR297_BLAST_RADIUS.md new file mode 100644 index 00000000..ef781805 --- /dev/null +++ b/docs/PR297_BLAST_RADIUS.md @@ -0,0 +1,99 @@ +# PR #297: blast radius and review boundaries + +This is a broad runtime + test/CI PR, not a low-risk documentation-only feature. +The **current update** adds documentation only. Refer to the +[behavior comparison](PR297_BEFORE_AFTER.md) and [test evidence/replay](PR297_TEST_MATRIX.md). + +## Impact by subsystem + +| Subsystem / class | Runtime and users affected | Configuration / persistence | Requests / error policy | Potential failures and validation priority | +| --- | --- | --- | --- | --- | +| Login command UX (user-visible) | Matrix users with login permission invoking `login`; provisioning still uses existing flow IDs | Existing `network.qr_login`; no new required option. Framework permissions remain | Help/invalid/disabled methods do not start provider auth; aliases delegate to same flow | Handler registration/permissions regression; test actual Processor dispatch, disabled QR and full IDs before live auth | +| QR/email auth and stored-credential recovery (high, account safety) | New QR/email sign-ins, re-authentication, token fallback, session establishment | Claims/cooldowns are memory-only, keyed by `User.MXID`; existing stored credentials, certificate, token/key metadata and finalization reused | Overlap blocked, wait enforced before new auth request including stored credentials. Default rate/temporary waits plus longer Retry-After. Not an automatic retry scheduler | Can delay legitimate reconnect or suppress useful provider wording; different Matrix users for same LINE account not coordinated; one Matrix user's several logins share gate. Test cancel/error claim release and stages; use disposable account. Restart clears local cooldown, not LINE's restriction; never recommend restart as bypass | +| Identity lookup, search, portals (high, routing) | Public ID start-chat, lookup-only, MID input, contact search/list | Validated canonical MID + receiver forms portal key; existing portal DB APIs reused; no rewrite of old bad public-ID portals | Public IDs resolved afresh; contact cache keyed by MID. Exact result returns without contact enumeration; fallback search still batches contacts, max 20 results from #296. Empty search no lookup. Errors now visible rather than empty successful result | Unsupported real IDs/provider shapes rejected; old malformed portals can coexist until operator-reviewed cleanup; wrong-recipient routing is critical. Verify known contact, mismatched IDs and portal reuse/reopen before sending | +| Outbound send/error/ack path (high, reliability) | Matrix-to-LINE text/media send paths using sendMessage | Existing sequence allocation/retry/DB ack mechanisms retained; no new persistent dedup store | Pass caller context; preserve provider errors for existing recovery; reject empty ack ID | Cancellation/timeout may occur after LINE accepted message. Re-sending can duplicate; one mock request is not a global exactly-once guarantee. Test failures/ack absence and verify with recipient before live resend | +| Inbound media / metadata (high, integrity/compatibility) | Encrypted image/file/audio/video/thumbnail paths, including shared handler/direct-media decryption; inbound image metadata | No new option; no key/device reset or crypto-store change | Authenticate before plaintext; video-specific MAC input. No new 60-retry OBS policy. MIME/size/dimensions based on received image bytes | Previously tolerated unauthenticated bytes now rejected; real unsupported MAC formats could fail. Synthetic crypto is not full server compatibility. Test each supported type with benign small fixtures. MIME recognition doesn't imply HEIC/AVIF decoder/transcoder coverage | +| Preview/log/sync changes (internal reliability/privacy) | Preview failures, login/send diagnosis, conversation sync logs | Existing structured logging; no new debug config or raw HTTP logging | Preview upload already upstream; safer failure logs and test seam here. Sync policy unchanged: connect/fullSync/backfill reconciliation remains, only trigger labels added | Existing logs still carry sensitive IDs/URLs/error context; sanitize. No claim of eliminating bulk sync or all raw error exposure | +| Native test support / fixtures (mostly test, small runtime API surface) | `pkg/ltsm/memory.go` exposes existing malloc/free wrappers for bounded heap fixtures; other changes in tests | No generated WASM crypto edits, no default runtime policy change | No LINE traffic in test suite | Memory wrappers must only be used correctly; tests retain real exhaustion/recovery, isolation and stress loops. Not a speculative protocol change | +| Test/CI/build infrastructure (not bridge request behavior) | Developers, fork/upstream Actions, build consumers | Per-package floor file, Python checker, scripts; no production config required | Local fast/normal/race/coverage/release; CI normal/race/coverage and caches; fork Docker pushes and deployment restricted | CI resource/cache/coverage regressions; source checking is not live compatibility. Build uses fixed epoch optionally and explicit tag/SHA; same source with different tag state can hash differently | + +## Database and stored-state review + +The diff against the integrated upstream base changes **no SQL/migration/schema +files**, no `go.mod`/`go.sum` framework version, and no fields in +`UserLoginMetadata` or its JSON tags. There is no PR-specific schema migration. +Normal framework DB version checks still run at startup; deployments from other +versions must follow their framework's migration rules. + +Persistent effects still exist: successful login finalization stores tokens, +certificates and E2EE metadata using existing mechanisms; successful canonical +resolution/creation writes ghosts, portals and Matrix room/membership state. +Failed lookups now avoid creating portal rows. No backfill/rekey/migration of +previous wrongly keyed portals is included. Cooldown/active-claim maps are not +persisted. Existing Matrix crypto state/encryption devices are not reset, +disabled or repaired by this PR. The known puppet sender/device ownership +warning remains unresolved. + +## What is untouched / inherited, not a new fix + +- Homeserver configuration, Synapse, appservice registration format and database + schema are not changed by the PR. +- Existing upstream media-size handling at the integrated base, HEIC/audio/ + sticker improvements, bridgev2 room creation and send retry/sequence mechanisms + are not replaced wholesale. Later main changes need separate reconciliation. +- Group-message key-spec logic has additional tests here, not a new algorithm. +- No new auto-login probing, restriction bypass, contact mutation policy, + unconditional room enumeration policy or Matrix encryption weakening. + +## Upgrade / rollback guidance for a reviewer + +Prefer an isolated bridge with a disposable LINE account. Save exact binary, +config and consistent DB backups before installing; keep a test window and +record source/SHA. Run startup + help/lookup before login/send/media tests. +Avoid running both bridge and Chrome-extension session on the same account. + +Binary rollback is straightforward mechanically (restore preserved binary with +original owner/mode, restart **only the test bridge**) because no PR-specific +schema migration is introduced. It is not a full state rollback: server-accepted +messages, remote session invalidation, newly created portals, room membership and +new login metadata cannot be undone by replacing the executable. Preserve state +for diagnosis; do not delete credentials/DB rows, restore a running DB blindly, +retry an ambiguous send, reset crypto devices, or disable encryption as recovery. +If unexpected identity/portal routing or request bursts appear, stop the test +and report a sanitized timestamp/category rather than probing more accounts. + +## Separate CI dependency issue found during documentation review + +The normal test job's explicit alias step runs `scripts/test.sh fast`, which +invokes `staticcheck`. That job installs Go and libolm but does not install +`staticcheck`; the separate lint job's installation does not share its runner. +On a runner without that tool the alias step will fail before tests. Local +validation had the tool on PATH; GitHub reports no checks here, so this is a +workflow dependency gap found by source inspection, not an observed CI failure. +It needs a focused follow-up (install the tool in that job or use an appropriate +test-only invocation while retaining lint elsewhere). No workflow/application +change was folded into this documentation commit. + +## Suggested smaller review units + +The reviewer explicitly asked for smaller chunks. A sensible follow-up split is: + +1. **Identity/DM resolution:** resolver + context-aware lookup RPCs + MID and + portal tests; preserve #296 search cap and newer avatar changes. +2. **Authentication/account safety:** QR lifecycle + typed/sanitized errors + + HTTP Retry-After + shared claims/cooldowns + reconnect tests. These are + coupled and should be validated together with upstream #294/#298. +3. **Messaging/media:** context-aware sends and ack validation, and separately + authenticated inbound media/image metadata with #189/#196 attribution. + Identity-aware send tests currently reuse resolver fixtures; extract shared + fixture support or state an explicit test dependency rather than duplicate it. +4. **Test performance/CI/build:** deterministic fixture and native heap support, + then scripts/coverage/cache/fork safeguards and reproducible build metadata; + coverage baselines must be measured for each resulting runtime branch. + +These are proposed boundaries, **not already-created independent PRs**. #297 +consolidates overlapping #271 and fork #1 work; those PRs remain open and their +branches untouched. Do not merge overlapping versions independently. Current +main `10e0a5f` conflicts in `client.go`; resolve/review on separate focused +branches with fresh tests before treating any split as merge-ready. No runtime +conflict resolution or history rewrite was done for this documentation request. diff --git a/docs/PR297_TEST_MATRIX.md b/docs/PR297_TEST_MATRIX.md new file mode 100644 index 00000000..35632a32 --- /dev/null +++ b/docs/PR297_TEST_MATRIX.md @@ -0,0 +1,139 @@ +# PR #297: reproducible test matrix and evidence + +See [before/after](PR297_BEFORE_AFTER.md) for the comparison boundary and +[blast radius](PR297_BLAST_RADIUS.md) for risks/rollback. This is a replay guide, +not a claim that mocked tests validate LINE's production behavior. + +## Evidence and statuses + +- **AUTOMATED PASS**: local fixture/mock execution, no live LINE services. +- **LIVE VERIFIED**: a specific observed live result with attributable source. +- **MANUAL NOT RUN**: no attributable per-scenario live evidence. +- **NOT TESTABLE WITHOUT PROVIDER CONDITIONS**: do not provoke a restriction. +- **KNOWN LIMITATION**: no claim of a fix/guarantee. + +### What was actually run + +| Evidence | Source / environment | Result and boundary | +| --- | --- | --- | +| Historical full normal/race/coverage/release | Integrated runtime commit `07badf92460e5ae744b90d1c0c0a46cc30396218`; Go 1.26.10, Linux amd64, CGO/libolm 3.2.16 | Each suite: 512 pass test/subtest events, 1 skip, 0 failures. Release repeats all three plus format/vet/staticcheck/module verification/build/coverage-checker tests. All package floors passed. Source diff from this commit through pre-documentation head `81f502150b88e681d2703239df608b1b38ccc2d6` is only `docs/UPSTREAM_INTEGRATION_STATUS.md`; runtime/tests/CI are identical. This is reused evidence, not a newly run exact-head full suite. | +| Fresh targeted replay, 2026-11-02 | `81f502150b88e681d2703239df608b1b38ccc2d6`, before this documentation-only change; same local Go/libolm setup | 146 pass test/subtest events, 0 skips/failures. `fast` also ran gofmt/vet/staticcheck on the three selected packages. Exact command below. | +| Historical skip | `pkg/runner_vectors_replay_test.go`: `TestReplayLTSMVectors` | Explicit existing skip: old JSON IPC replay needs rewriting for the typed/wazero runner. Supplying vectors does not enable this test. This is not a blanket skip of crypto/stress tests. | +| Checkpoint build | `f36c646b468719877c9fb4329f2cab18b652fbca`, v1.2.0+dev.f36c646b | Two same-environment, fixed-epoch builds were identical. Tagged artifact SHA256 `dabf2c816b752341328411ab02fa73d756c20b82097abab38a46e38916649c81`; not a build of today's upstream merge. | +| Historical production startup | Same `f36c646b` version; working binary SHA256 `a8483a0342fc9b0715ec087a6c773a0b40a8b63c122895cc714940c9bee7f4b8` | **LIVE VERIFIED** startup/service active, DB schemas current, Matrix appservice connectivity. This binary predates the checkpoint tag: same source/time/toolchain but empty embedded tag, hence different checksum. Not evidence for every login/media scenario or the PR's later upstream integration. | +| Author's live report | [PR comment](https://github.com/beeper/line/pull/297#issuecomment-6546539362) reports use over multiple days | Useful operational feedback, but no per-scenario/source inventory. Version attribution for individual scenarios is uncertain; do not label them LIVE VERIFIED. | +| GitHub CI | No checks reported when inspected on 2026-11-02 | Not a green CI claim; PR conflicts with newer main. No tests in this guide validate the unresolved combined tree. | + +### Automated replay (no LINE credentials needed) + +Use Go 1.26+, libolm development headers, Python 3, `goimports`, and `staticcheck` +on PATH. Run in a checkout of **this PR branch**, not the production data folder. +Record `git rev-parse HEAD`, `go version`, and `pkg-config --modversion olm`. +Package/event counts include nested subtests, not 512 independent user journeys. + +```sh +TEST_JSON=1 TEST_RUN='Test(RegisteredLineLoginCommandDispatch|ResolveIdentifier|StartChat|OpaqueMID|LookupToOutbound|QR|LoginCooldown|LoginClaim|LoginRateLimit|EmailLoginRate|StoredCredentialRecovery|VerificationTemporarily|CanceledEmail|SubmitUserInputLogs|SessionEstablishment|ReconnectError|SanitizeProvider|ParseRetryAfter|HTTPErrorPreserves|Outbound|SendMessage|MediaHMAC|LineImage|ConvertTextUploads)' \ + bash scripts/test.sh fast ./pkg/connector ./pkg/line ./pkg/connector/handlers + +# Full validation, optional for reviewers but required after integrating conflicts: +bash scripts/test.sh normal +bash scripts/test.sh race +bash scripts/test.sh coverage +bash scripts/test.sh release +``` + +`release` reruns normal/race/coverage and builds a local binary; it never deploys. +Use `TEST_PACKAGE_PARALLELISM=2`, `GOMAXPROCS=4` (defaults) for bounded load. +Coverage floors stay enabled. CI matrix runs normal/race/coverage, not `release`. + +## Automated implementation-to-test map + +Paths are relative to repository root. All tests below use synthetic fixtures, +mock transports/Matrix intents and (where needed) temporary SQLite databases; +none logs in to a real LINE account. HMAC/crypto tests execute local crypto. + +| Source path / behavior | Test file and function | Key assertion / method | +| --- | --- | --- | +| `pkg/connector/login_command.go`, `connector.go`: registration and selection | [login_command_test.go](../pkg/connector/login_command_test.go): `TestRegisteredLineLoginCommandDispatch` | Real mautrix-go Processor initialized by bridge; help, aliases/case, full IDs, unknown/disabled flow; mock `CreateLogin` stops before auth. Checks framework login-permission requirement retained, not a comprehensive permissions suite. | +| `pkg/connector/login_qr.go`: lifecycle | [login_qr_test.go](../pkg/connector/login_qr_test.go): `TestQRConcurrentWaitIsRejected`, `TestQRApprovalPINAndTokenValidation`, `TestQRExpiryAndCancellation`, `TestQRStartPreservesDeadline`, `TestQRPasswordlessReconnectNeedsApproval` | Mock scan/PIN/token responses, reject incomplete finalization, concurrent wait and expired/canceled attempts; fresh approval required for passwordless reconnect. | +| `pkg/line/qr.go`, `auth_error.go`: request/stage/provider errors | [qr_contract_test.go](../pkg/line/qr_contract_test.go): `TestQRRequestContractAndMIDRepresentations`; [qr_error_test.go](../pkg/line/qr_error_test.go): `TestQRErrorDetailsAndRedaction`, `TestQRAuthErrorStage`, `TestQRRPCErrorAuthError`, `TestParseRetryAfter`, `TestHTTPErrorPreservesRetryAfter` | HTTP fixtures verify wire fields, numeric codes, type-specific classification, stage, safe fields, seconds/date retry guidance. | +| `pkg/connector/loginerrors.go`, `connector.go`: surfaced safe errors | [login_error_logging_test.go](../pkg/connector/login_error_logging_test.go): `TestSubmitUserInputLogsStructuredLoginErrorWithoutCredentials`, `TestSessionEstablishmentErrorKeepsStageAndSafeMessage`; [auth_error_test.go](../pkg/line/auth_error_test.go): `TestSanitizeProviderMessage`; [qr_failure_test.go](../pkg/connector/qr_failure_test.go): `TestQRVerificationTemporarilyUnavailable` | Capture structured logs/errors; no fixture secrets; safe provider text retained, unsafe substrings suppressed/redacted. | +| `pkg/connector/login_cooldown.go`, `client.go`: retry safety | [login_cooldown_test.go](../pkg/connector/login_cooldown_test.go): `TestQRRateLimitIsReportedAndBlocksImmediateRetry`, `TestLoginClaimSerializesSameAccount`, `TestLoginCooldownUsesRetryAfter`, `TestStoredCredentialRecoveryRespectsActiveCooldown`, `TestStoredCredentialRecoveryStartsCooldownAfterProviderLimit`, `TestStoredCredentialRecoveryHonorsHTTPRetryAfter` | Count fake requests, no extra provider call during wait; timers/claims scoped by Matrix user; stored credentials cannot bypass active cooldown. | +| `pkg/connector/resolve_identifier.go`, `userinfo.go`: lookup/portal | [resolve_identifier_test.go](../pkg/connector/resolve_identifier_test.go): `TestResolveIdentifierPublicMIDAndSearch`, `TestResolveIdentifierFailuresDoNotPersistPortals`, `TestResolveIdentifierConcurrentCanonicalPortalReuse`, `TestStartChatCreatesReusesAndRetriesRoom` | Temp DB + framework provisioning path; validated public-ID/MID key, no failed-lookup persistence; create, reuse, creation retry and reopen after leaving. | +| Same plus `pkg/line/user_mid.go`: strict identity | [opaque_mid_test.go](../pkg/connector/opaque_mid_test.go), [user_mid_test.go](../pkg/line/user_mid_test.go), [identity_audit_test.go](../pkg/connector/identity_audit_test.go): `TestLookupToOutboundIdentityRepresentations`; resolver `TestResolveIdentifierDeadlineAndMismatchedMID`, `TestSearchUsersRejectsUnrequestedContactMID`, `TestSearchUsersEmptyQueryDoesNotEnumerateContacts` | Case preserved for gateway IDs, canonical legacy IDs, reject mismatched/unrequested/ambiguous result, no empty-query enumeration, same outbound recipient. | +| `pkg/connector/send_message.go`, `pkg/line/methods.go`: send/ack | [outbound_pipeline_test.go](../pkg/connector/outbound_pipeline_test.go): `TestOutboundOwnerAndCanonicalRecipient`, `TestOutboundRequestCancellation`; [send_message_response_test.go](../pkg/line/send_message_response_test.go): `TestSendMessageRejectsMissingAcknowledgement`, `TestSendMessagePreservesAuthenticationError` | Correct sender/recipient/login, one mocked normal send, no false success on reject/empty ack; blocked HTTP observes context cancellation. Not exactly-once under lost acknowledgements. | +| `pkg/connector/media.go`, handlers: integrity | [media_hmac_test.go](../pkg/connector/media_hmac_test.go): `TestMediaHMAC`; [handler_test.go](../pkg/connector/handlers/handler_test.go) | Real local encryption/MAC fixtures for image/file/audio/video/thumbnail; tampered ciphertext/MAC and wrong MAC mode rejected; handler key/decrypt routing. | +| `pkg/connector/handlers/image_media.go`, `image.go`, `text.go`: metadata/previews | [image_media_test.go](../pkg/connector/handlers/image_media_test.go): `TestLineImageMediaInfo`, `TestLineImageBrands`, `TestLineImageExtensions`; [text_test.go](../pkg/connector/handlers/text_test.go): `TestConvertTextUploadsOnlyImageLinkPreviews` | Generated image/config fixtures and bounded brand parsing; mock download/upload verifies preview MXC, no raw CDN image reference; failures retain text. | +| Test/runtime support, CI/build scripts | [runner_secret_test.go](../pkg/runner_secret_test.go), [memory_test.go](../pkg/ltsm/memory_test.go), [coverage checker tests](../scripts/test_check_test_coverage.py) | Native heap abort/recovery remains exercised; deterministic concurrency fixtures; checker rejects coverage regression. Repro build is historical measured evidence above, not a claim about cross-host builds. | + +## Safe live replay order + +Use a **disposable/secondary LINE account** and a consenting test contact. Record +the exact binary version/hash and source; use a separate bridge/test data set. +Back up binary, config and database using your normal consistent backup process. +Do not attach a second Chrome-extension client: it can invalidate the bridge's +session. The branch currently conflicts with main; test the isolated branch, +not an unreviewed conflict resolution. Do not overwrite an important deployment +just to replay this matrix. + +Commands below are in the bot management room. In other rooms prefix with your +configured prefix (for example `!line`). `` means an exact public ID +shared by the test contact, **not** their display name. `` is a verified +internal user identifier, not a guessed `M_id` string. With several logins, +select the intended login using framework help before any create/send action. + +| ID / feature | Preconditions | Exact command or action | Expected result / prior behavior | Status | Risk | Cleanup / recovery | +| --- | --- | --- | --- | --- | --- | --- | +| S01 startup/session | Isolated deployment with preserved existing session | Start test bridge normally; inspect health and management-room `list-logins` | DB/appservice initialize; existing login stays usable where provider permits. Not a new login request | LIVE VERIFIED for historical `f36c646b` startup only; MANUAL NOT RUN on current PR integration | Medium | Stop test bridge and restore backup on fatal startup; never delete sessions to fix a symptom | +| L01 help/unknown | No login required; command permission | `login`, then `login help`, then `login invalid` | Friendly options/guidance, no provider auth; before: full IDs / invalid help | AUTOMATED PASS; MANUAL NOT RUN with exact-head live evidence | Low | None | +| L02 disabled QR | Separate test config with existing `network.qr_login: false` | `login help`, then `login qr` | No QR advertised; useful unavailable error and no QR request | AUTOMATED PASS; MANUAL NOT RUN | Low/medium (config restart) | Restore original option; restart only test bridge if changed | +| L03 email/full ID selection | Only if testing a new disposable session is necessary | `login email`, then `cancel`; alternatively `login dev.highest.matrix.line.email_login` then `cancel` | Credential prompt, no password in command history; full IDs remain valid | AUTOMATED PASS; MANUAL NOT RUN | Medium | Cancel; do not submit primary credentials or duplicate login attempts | +| Q01 one QR lifecycle | QR enabled; login actually needed, mobile available | **Choose one**: `login qr` or `login dev.highest.matrix.line.qr_login`; scan once; approve/PIN only in expected UI | QR → approval/PIN if requested → validated session; before had concurrency/deadline error problems, not missing QR protocol | AUTOMATED PASS; MANUAL NOT RUN per-scenario live evidence | High | `cancel` if abandoned; stop on rejection and observe provider guidance; do not loop | +| Q02 overlap/cancel/expiry | Prefer automated test; optional disposable pending QR | Abandon one pending attempt with `cancel`; for expiry let it expire naturally | No persisted incomplete login; claim released; errors distinguish deadline/cancel | AUTOMATED PASS; MANUAL NOT RUN | High | Cancel only pending login; do not repeatedly generate QR codes for stress testing | +| A01 restriction/provider messages | Only a naturally occurring 429, RETRY_LATER, or verification-unavailable error | Record displayed category/stage and UTC, not QR/token/body; do not induce rate limit | Useful safe message + wait; defaults 15/5/10 min, Retry-After can extend. Duration is local policy, not LINE restriction duration | AUTOMATED PASS; NOT TESTABLE WITHOUT PROVIDER CONDITIONS | High | Honor wait; no bypass/restart to clear local gate; use mocked tests for immediate-retry assertions | +| I01 resolve public ID | Logged-in secondary account; known consenting contact | `search `; optionally `resolve-identifier ` as listed in framework help | Validated exact contact; lookup-only does not create a DM; no false “shadow ban” inference from failure | AUTOMATED PASS; MANUAL NOT RUN | Medium | Stop on mismatch; do not contact unverified results | +| I02 create/reuse/reopen | I01 succeeds, intended account confirmed | `start-chat `; repeat once; optionally `start-chat ` for same contact; leave test room then `start-chat ` | Same canonical recipient/login; reuse/rejoin existing room where possible, not duplicate portal; before public string could create wrong portal key | AUTOMATED PASS; MANUAL NOT RUN | High (rooms/membership persist) | Stop if wrong recipient/duplicate; do not delete DB rows manually; keep IDs private for operator diagnosis | +| I03 malformed/ambiguous IDs | Automated only for synthetic server results | Run resolver/MID tests above; do not deliberately query strangers | Reject mismatched/ambiguous/unrequested MID before portal persistence; opaque case unchanged | AUTOMATED PASS; NOT TESTABLE WITHOUT PROVIDER CONDITIONS for malformed server data | High | No live fault injection needed | +| M01 outbound | Known correct test portal; cooperating contact | Send one neutral unique text from Matrix, inspect LINE receipt | Server ack maps to event; one observed message; before empty ack could look successful | AUTOMATED PASS; MANUAL NOT RUN | High | Do not resend on timeout until recipient checks; deletion not required | +| M02 inbound/attribution | M01 verified | Contact sends one neutral text from LINE to Matrix | Correct room/sender, one observed event; inbound text routing largely pre-existing | MANUAL NOT RUN; automated identity tests cover only part of this | Medium | Stop on unexpected portal/sender; preserve sanitized timestamps only | +| M03 E2EE warning | Existing encrypted test room | Observe inbound puppet event for “sender ... does not match ... owner of the device” | Known unresolved warning; no fix claimed | KNOWN LIMITATION | High if mistaken for delivery/integrity guarantee | Do not reset crypto/devices or disable encryption to hide warning | +| F01 media | Correct test portal; non-private fixtures | Contact sends one small JPEG, PNG, supported video, audio and file; inspect dimensions/MIME/download in Matrix | Valid media displays; MIME/name/size and dimensions where supported; authenticated bytes only | AUTOMATED PASS for synthetic integrity/metadata; MANUAL NOT RUN across live formats | High | Stop on HMAC failure; do not disable verification; keep original fixture privately | +| F02 corruption / unusual formats | Local fixtures only | Run `TestMediaHMAC` and `TestLineImageBrands`; optional benign WebP/HEIC/AVIF from disposable contact | Tampered fixture rejected; unsupported dimensions may be absent; not universal codec support | AUTOMATED PASS; MANUAL NOT RUN for live format combinations | Medium/high | No tampering with production traffic; no repeated OBS retry workaround | +| F03 link preview | Benign non-private URL, previews enabled | Send one text with a harmless public link in test chat | Image preview uploads to Matrix; failure still delivers text (upload behavior already in base) | AUTOMATED PASS; MANUAL NOT RUN | Medium (downloads) | Disable only test preview if needed; don't publish URLs/raw errors | +| R01 send cancellation | Automated mock preferred | Run `TestOutboundRequestCancellation`; do not disconnect a production bridge mid-send | Mock request canceled promptly; live uncertain delivery remains possible | AUTOMATED PASS; MANUAL NOT RUN for real interruptions | High | No blind resends; verify recipient before retry | +| R02 reconnect/session | Only on isolated bridge with known session | Optional orderly test-bridge restart once; verify `list-logins` and one controlled message | Stored state retained where possible; passwordless failed recovery asks fresh approval rather than inventing credentials | AUTOMATED PASS for selected recovery cases; MANUAL NOT RUN across network/session matrix | High | Restore backup if necessary; do not invalidate mobile account or trigger repeated auth | +| O01 sync/log review | UTC window around above tests | Inspect structured fields locally, see safe projection below | No new bulk-sync policy; existing connect/fullSync sync still runs; exact lookup avoids contact enumeration | MANUAL NOT RUN for exact-head live network frequency; code-diff evidence | Medium/privacy | Stop on unintended portals or request bursts; don't publish raw logs | + +### Safe diagnostic collection + +Prefer existing structured logging; no new config key or unrestricted HTTP-body +logging is needed. Logs can contain MIDs, Matrix IDs, message IDs, URLs and error +text even when the new auth messages are sanitized. Inspect a narrow UTC test +window locally. For JSON logs, a public-shareable **numeric/boolean-only** +projection can be made without dumping raw production data: + +```sh +# Set an ISO UTC START/END and your actual log path locally. +jq -c --arg start "$START" --arg end "$END" ' + select(.time >= $start and .time <= $end) | + {time, http_status, line_response_code, line_error_code, + retry_recommended, temporary, cooldown_triggered, success} | + with_entries(select(.key == "time" or + (.value | type) == "number" or (.value | type) == "boolean")) +' /path/to/logs/bridge.log +``` + +Verify your logger's time field/format first (it may use `timestamp` instead of +`time`). Privately correlate stage/category and identifiers if necessary, then +manually redact before sharing. Never publish QR/session tokens, credentials, +cookies, encryption keys, private text, complete logs, or raw provider responses. + +## What has NOT been established + +No complete live multi-account/login/client matrix; no proof of every LINE +restriction category or its duration; no guarantee against account restrictions; +no exhaustive network-interruption/reconnect testing; no exactly-once delivery +under lost acknowledgements; no all-format/all-size media, group/LS-on/LS-off, +direct-media or all-Matrix-client coverage. No new-current-upstream merged-tree +validation, GitHub CI success, E2EE device-ownership warning fix, or historical +per-scenario live inventory. Optional lookup-shape diagnostics remain deferred. diff --git a/docs/UPSTREAM_INTEGRATION_STATUS.md b/docs/UPSTREAM_INTEGRATION_STATUS.md index 84ebcc02..c3fccc1f 100644 --- a/docs/UPSTREAM_INTEGRATION_STATUS.md +++ b/docs/UPSTREAM_INTEGRATION_STATUS.md @@ -1,5 +1,19 @@ # Upstream integration status +## Reviewer update (2026-11-02) + +PR [#297](https://github.com/beeper/line/pull/297) is published. It now has a +[before/after comparison](PR297_BEFORE_AFTER.md), a +[reproducible test/evidence matrix](PR297_TEST_MATRIX.md), and a +[blast-radius/split assessment](PR297_BLAST_RADIUS.md). These supersede the +publication/CI status recorded below. Upstream main advanced to `10e0a5f`; +the PR is conflicting in `pkg/connector/client.go` and has no reported GitHub +checks. No runtime conflict resolution was performed in this documentation update. + +The sections below retain the historical integration and validation record; +the earlier CLI authentication blocker was subsequently resolved using the +normal user configuration. They are not a claim of current-main compatibility. + ## Verified sources - Current upstream baseline fetched from `origin/main`: `45a48b9b3b57330d99d326d443f9a9fc8725a48f`.