diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index c234f81..db46c87 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -32,6 +32,7 @@ func (lc *LineClient) newMessageHandler() *handlers.Handler { IsLoggedOut: lc.isLoggedOut, NewClient: func() *line.Client { return lc.newClient() }, DecryptMedia: lc.decryptImageData, + DecryptVideoMedia: lc.decryptVideoData, } } diff --git a/pkg/connector/handlers/audio.go b/pkg/connector/handlers/audio.go index 02ae5af..0df10a2 100644 --- a/pkg/connector/handlers/audio.go +++ b/pkg/connector/handlers/audio.go @@ -97,10 +97,10 @@ func (h *Handler) ConvertAudio(ctx context.Context, portal *bridgev2.Portal, int if encKM := data.ContentMetadata["ENC_KM"]; encKM != "" && len(audioData) > 32 { decryptedAudio, err := h.DecryptMedia(audioData, encKM) if err != nil { - h.Log.Warn().Err(err).Msg("ENC_KM fallback decrypt failed, sending raw audio") - } else { - audioData = decryptedAudio + h.Log.Error().Err(err).Msg("ENC_KM fallback decrypt failed") + return nil, fmt.Errorf("failed to decrypt audio data from ENC_KM: %w", err) } + audioData = decryptedAudio } } diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 88e78a0..1b56a03 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -24,6 +24,8 @@ type Handler struct { // DecryptMedia decrypts E2EE encrypted media data using the given key material. DecryptMedia func(data []byte, keyMaterial string) ([]byte, error) + // DecryptVideoMedia decrypts E2EE encrypted video data, whose HMAC covers chunk hashes. + DecryptVideoMedia func(data []byte, keyMaterial string) ([]byte, error) } // tryRecoverClient attempts token recovery on auth errors and returns a fresh client. diff --git a/pkg/connector/handlers/video.go b/pkg/connector/handlers/video.go index c91cd3c..5af0339 100644 --- a/pkg/connector/handlers/video.go +++ b/pkg/connector/handlers/video.go @@ -88,7 +88,7 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int Str("file_name", decryptInfo.FileName). Msg("Decrypting E2EE video") - decryptedVideo, err := h.DecryptMedia(videoData, decryptInfo.KeyMaterial) + decryptedVideo, err := h.DecryptVideoMedia(videoData, decryptInfo.KeyMaterial) if err != nil { h.Log.Error().Err(err).Msg("Failed to decrypt video data") return nil, fmt.Errorf("failed to decrypt video data: %w", err) @@ -108,13 +108,13 @@ func (h *Handler) ConvertVideo(ctx context.Context, portal *bridgev2.Portal, int Str("enc_km_preview", encKM[:min(20, len(encKM))]+"..."). Msg("Decrypting video using ENC_KM from metadata (fallback)") - decryptedVideo, err := h.DecryptMedia(videoData, encKM) + decryptedVideo, err := h.DecryptVideoMedia(videoData, encKM) if err != nil { - h.Log.Warn().Err(err).Msg("ENC_KM fallback decrypt failed, sending raw video") - } else { - videoData = decryptedVideo - h.Log.Info().Int("decrypted_size", len(videoData)).Msg("Successfully decrypted video from ENC_KM") + h.Log.Error().Err(err).Msg("ENC_KM fallback decrypt failed") + return nil, fmt.Errorf("failed to decrypt video data from ENC_KM: %w", err) } + videoData = decryptedVideo + h.Log.Info().Int("decrypted_size", len(videoData)).Msg("Successfully decrypted video from ENC_KM") } } diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 5f2cecd..81cae51 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -25,6 +25,16 @@ import ( // 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) @@ -39,7 +49,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) @@ -50,7 +60,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 { @@ -59,8 +76,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_test.go b/pkg/connector/media_test.go new file mode 100644 index 0000000..cbbf6c4 --- /dev/null +++ b/pkg/connector/media_test.go @@ -0,0 +1,111 @@ +package connector + +import ( + "bytes" + "testing" +) + +func TestDecryptImageDataVerifiesHMAC(t *testing.T) { + lc := &LineClient{} + plaintext := []byte("file-like LINE E2EE media") + + encrypted, keyMaterial, err := lc.encryptFileData(plaintext) + if err != nil { + t.Fatalf("encryptFileData() error = %v", err) + } + + decrypted, err := lc.decryptImageData(encrypted, keyMaterial) + if err != nil { + t.Fatalf("decryptImageData() error = %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("decryptImageData() = %q, want %q", decrypted, plaintext) + } + + tamperedCiphertext := bytes.Clone(encrypted) + tamperedCiphertext[0] ^= 0x01 + if _, err := lc.decryptImageData(tamperedCiphertext, keyMaterial); err == nil { + t.Fatal("decryptImageData() with tampered ciphertext succeeded, want error") + } + + tamperedMAC := bytes.Clone(encrypted) + tamperedMAC[len(tamperedMAC)-1] ^= 0x01 + if _, err := lc.decryptImageData(tamperedMAC, keyMaterial); err == nil { + t.Fatal("decryptImageData() with tampered HMAC succeeded, want error") + } +} + +func TestDecryptThumbnailDataVerifiesHMAC(t *testing.T) { + lc := &LineClient{} + plaintext := []byte("LINE E2EE thumbnail") + + _, keyMaterial, err := lc.encryptFileData([]byte("parent media")) + if err != nil { + t.Fatalf("encryptFileData() error = %v", err) + } + encrypted, err := encryptThumbnail(plaintext, keyMaterial) + if err != nil { + t.Fatalf("encryptThumbnail() error = %v", err) + } + + decrypted, err := lc.decryptImageData(encrypted, keyMaterial) + if err != nil { + t.Fatalf("decryptImageData() error = %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("decryptImageData() = %q, want %q", decrypted, plaintext) + } + + tamperedCiphertext := bytes.Clone(encrypted) + tamperedCiphertext[0] ^= 0x01 + if _, err := lc.decryptImageData(tamperedCiphertext, keyMaterial); err == nil { + t.Fatal("decryptImageData() with tampered thumbnail ciphertext succeeded, want error") + } + + tamperedMAC := bytes.Clone(encrypted) + tamperedMAC[len(tamperedMAC)-1] ^= 0x01 + if _, err := lc.decryptImageData(tamperedMAC, keyMaterial); err == nil { + t.Fatal("decryptImageData() with tampered thumbnail HMAC succeeded, want error") + } +} + +func TestDecryptVideoDataVerifiesChunkHashHMAC(t *testing.T) { + lc := &LineClient{} + plaintext := bytes.Repeat([]byte("video"), 40000) + + encrypted, keyMaterial, err := lc.encryptVideoData(plaintext) + if err != nil { + t.Fatalf("encryptVideoData() error = %v", err) + } + + decrypted, err := lc.decryptVideoData(encrypted, keyMaterial) + if err != nil { + t.Fatalf("decryptVideoData() error = %v", err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Fatal("decryptVideoData() returned different plaintext") + } + + if _, err := lc.decryptImageData(encrypted, keyMaterial); err == nil { + t.Fatal("decryptImageData() accepted video HMAC mode, want error") + } + fileLikeEncrypted, fileLikeKeyMaterial, err := lc.encryptFileData(plaintext) + if err != nil { + t.Fatalf("encryptFileData() error = %v", err) + } + if _, err := lc.decryptVideoData(fileLikeEncrypted, fileLikeKeyMaterial); err == nil { + t.Fatal("decryptVideoData() accepted file-like HMAC mode, want error") + } + + tamperedCiphertext := bytes.Clone(encrypted) + tamperedCiphertext[0] ^= 0x01 + if _, err := lc.decryptVideoData(tamperedCiphertext, keyMaterial); err == nil { + t.Fatal("decryptVideoData() with tampered ciphertext succeeded, want error") + } + + tamperedMAC := bytes.Clone(encrypted) + tamperedMAC[len(tamperedMAC)-1] ^= 0x01 + if _, err := lc.decryptVideoData(tamperedMAC, keyMaterial); err == nil { + t.Fatal("decryptVideoData() with tampered HMAC succeeded, want error") + } +}