diff --git a/README.md b/README.md index 5ae99d3..ea953d4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![cicd](https://github.com/carderne/signal-export/actions/workflows/cicd.yml/badge.svg)](https://github.com/carderne/signal-export/actions/workflows/cicd.yml) [![PyPI version](https://badge.fury.io/py/signal-export.svg)](https://pypi.org/project/signal-export/) -**⚠️ WARNING: Because Signal Desktop has finally decided to protect the database encryption key, this tool currently won't work with the latest versions of Signal Desktop. It may be a while (or never) before it works again. I will share links to alternatives if I become aware of them. Discussion happening in [this thread](https://github.com/carderne/signal-export/issues/133).** +**⚠️ WARNING: Because the latest versions of Signal Desktop protect the database encryption key, this tool currently only works on macOS. +Solutions for Linux and Windows will hopefully come soon from the community. +Discussion happening in [this thread](https://github.com/carderne/signal-export/issues/133).** Export chats from the [Signal](https://www.signal.org/) [Desktop app](https://www.signal.org/download/) to Markdown and HTML files with attachments. Each chat is exported as an individual .md/.html file and the attachments for each are stored in a separate folder. Attachments are linked from the Markdown files and displayed in the HTML (pictures, videos, voice notes). diff --git a/pyproject.toml b/pyproject.toml index 81214fd..2cd8f76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "emoji ~= 2.0", "Markdown ~= 3.4", "typer[all] ~= 0.7", + "pycryptodome~=3.20.0", ] [tool.rye] diff --git a/requirements-dev.lock b/requirements-dev.lock index 47fa888..eb39138 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -69,6 +69,8 @@ platformdirs==4.2.0 pluggy==1.4.0 # via pytest pre-commit==3.6.2 +pycryptodome==3.20.0 + # via signal-export pygments==2.17.2 # via readme-renderer # via rich diff --git a/requirements.lock b/requirements.lock index 8ca58c8..0961f41 100644 --- a/requirements.lock +++ b/requirements.lock @@ -24,6 +24,8 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py +pycryptodome==3.20.0 + # via signal-export pygments==2.17.2 # via rich pysqlcipher3==1.2.0 diff --git a/sigexport/crypto.py b/sigexport/crypto.py new file mode 100644 index 0000000..e5f3fd4 --- /dev/null +++ b/sigexport/crypto.py @@ -0,0 +1,60 @@ +# Modified from: +# https://gist.github.com/flatz/3f242ab3c550d361f8c6d031b07fb6b1 + +import json +import subprocess +import sys +from pathlib import Path + +from Crypto.Cipher import AES +from Crypto.Hash import SHA1 +from Crypto.Protocol.KDF import PBKDF2 +from Crypto.Util.Padding import unpad +from typer import Exit, colors, secho + + +def get_key(file: Path) -> str: + with open(file, encoding="utf-8") as f: + data = json.loads(f.read()) + if "key" in data: + return data["key"] + elif "encryptedKey" in data: + if sys.platform == "darwin": + try: + pw = get_password() + return decrypt(pw, data["encryptedKey"]) + except Exception: + secho("Failed to decrypt Signal password", fg=colors.RED) + raise Exit(1) + else: + secho( + "Your Signal data key is encrypted, and descrypting" + "it is currently only supported on macOS", + fg=colors.RED, + ) + raise Exit(1) + + secho("No Signal decryption key found", fg=colors.RED) + raise Exit(1) + + +def get_password() -> str: + cmd = ["security", "find-generic-password", "-ws", "Signal Safe Storage"] + p = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8") # NoQA: S603 + pw = p.stdout + return pw.strip() + + +def decrypt(password: str, encrypted_key: str) -> str: + encrypted_key_b = bytes.fromhex(encrypted_key) + prefix = b"v10" + if not encrypted_key_b.startswith(prefix): + raise + encrypted_key_b = encrypted_key_b[len(prefix) :] + + salt = b"saltysalt" + key = PBKDF2(password, salt=salt, dkLen=128 // 8, count=1003, hmac_hash_module=SHA1) + iv = b" " * 16 + aes_decrypted = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted_key_b) + decrypted_key = unpad(aes_decrypted, block_size=16).decode("ascii") + return decrypted_key diff --git a/sigexport/data.py b/sigexport/data.py index 84a84b8..54184e3 100644 --- a/sigexport/data.py +++ b/sigexport/data.py @@ -4,22 +4,11 @@ from pathlib import Path from pysqlcipher3 import dbapi2 as sqlcipher -from typer import Exit -from sigexport import models +from sigexport import crypto, models from sigexport.logging import log -def get_key(file: Path) -> str: - with open(file, encoding="utf-8") as f: - data = json.loads(f.read()) - if "key" in data: - return data["key"] - elif "encryptedKey" in data: - return data["encryptedKey"] - raise Exit(1) - - def fetch_data( source_dir: Path, chats: str, @@ -30,7 +19,7 @@ def fetch_data( signal_config = source_dir / "config.json" log(f"Fetching data from {db_file}\n") - key = get_key(signal_config) + key = crypto.get_key(signal_config) contacts: models.Contacts = {} convos: models.Convos = {} diff --git a/sigexport/main.py b/sigexport/main.py index fe034f5..45efd50 100755 --- a/sigexport/main.py +++ b/sigexport/main.py @@ -97,7 +97,7 @@ def main( if verbose: cmd.append("--verbose") try: - p = subprocess.run( + p = subprocess.run( # NoQA: S603 cmd, # NoQA: S603 capture_output=True, text=True, @@ -163,7 +163,7 @@ def main( print(DATA_DELIM, json.dumps(data), DATA_DELIM) raise Exit() except (ImportError, ModuleNotFoundError): - secho("You set 'no-use-docker' but `pysqlcipher3` not installed properly") + secho("You set '--no-use-docker' but `pysqlcipher3` not installed properly") sys.exit(1) if list_chats: