Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/connector/handle_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
6 changes: 3 additions & 3 deletions pkg/connector/handlers/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/connector/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions pkg/connector/handlers/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand Down
25 changes: 21 additions & 4 deletions pkg/connector/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
111 changes: 111 additions & 0 deletions pkg/connector/media_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading