Exporting media from Signal Desktop

I wanted to get all the photos and files shared in a Signal group conversation out of the app and into a regular folder, for sharing them with a friend not using signal. Because Signal Desktop stores everything in an encrypted SQLite database, with attachments encrypted on disk, you can't just copy files out.

So, I wrote a small Python script that decrypts and exports them.

It runs as a single file with uv run:

# List your group conversations
uv run signal-export-media.py --list-groups

# Export all media from a group
uv run signal-export-media.py --group "OK C'est Cool" --output ./photos

How it works

Signal Desktop on Linux stores its database key encrypted in ~/.config/Signal/config.json, with a key stored in the GNOME keyring.

Each attachments sit in ~/.config/Signal/attachments.noindex/. Each one is encrypted with its own key, which is stored in the database.

So the flow is roughly:

  1. Get the DB encryption key from keyring
  2. Query the database for attachment metadata (paths, keys, filenames)
  3. For each attachment: decrypt and write to disk with the original filename

The script

You'll need sqlcipher installed (apt install sqlcipher or pacman -S sqlcipher).

Here's the full script:

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "cryptography",
#     "secretstorage",
# ]
# ///
"""Export media from a Signal Desktop group conversation to a folder."""

import argparse
import base64
import binascii
import hashlib
import hmac
import json
import mimetypes
import subprocess
import sys
from pathlib import Path

import secretstorage
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

SIGNAL_DIR = Path.home() / ".config" / "Signal"
DB_PATH = SIGNAL_DIR / "sql" / "db.sqlite"
ATTACHMENTS_DIR = SIGNAL_DIR / "attachments.noindex"
CONFIG_PATH = SIGNAL_DIR / "config.json"


def get_db_key() -> str:
    """Get the Signal DB Key from the Keyring"""
    encrypted_raw = binascii.unhexlify(
        json.loads(CONFIG_PATH.read_text())["encryptedKey"]
    )

    conn = secretstorage.dbus_init()
    collection = secretstorage.get_default_collection(conn)
    password = None
    for item in collection.get_all_items():
        attrs = item.get_attributes()
        if (
            attrs.get("xdg:schema") == "chrome_libsecret_os_crypt_password_v2"
            and attrs.get("application") == "Signal"
        ):
            password = item.get_secret()
            break

    if password is None:
        sys.exit("Could not find Signal keyring entry")

    derived = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1, dklen=16)
    ciphertext = encrypted_raw[3:]
    dec = Cipher(algorithms.AES(derived), modes.CBC(b" " * 16)).decryptor()
    plaintext = dec.update(ciphertext) + dec.finalize()
    return plaintext[: -plaintext[-1]].decode()


def query_db(db_key: str, sql: str) -> list[dict]:
    """Run a SQL query via sqlcipher CLI and return parsed JSON rows."""
    full_sql = f"PRAGMA key = \"x'{db_key}'\";\nPRAGMA cipher_compatibility = 4;\n.mode json\n{sql}\n"
    result = subprocess.run(
        ["sqlcipher", str(DB_PATH)], input=full_sql, capture_output=True, text=True
    )
    lines = [l for l in result.stdout.strip().splitlines() if l != "ok"]
    return json.loads("\n".join(lines)) if lines else []


def list_groups(db_key: str) -> list[dict]:
    return query_db(
        db_key,
        "SELECT id, name FROM conversations WHERE type = 'group' AND active_at IS NOT NULL ORDER BY active_at DESC;",
    )


def get_attachments(db_key: str, conversation_id: str) -> list[dict]:
    return query_db(
        db_key,
        f"""SELECT path, contentType, fileName, size, localKey, sentAt
            FROM message_attachments
            WHERE conversationId = '{conversation_id}'
              AND path IS NOT NULL
              AND localKey IS NOT NULL
              AND attachmentType = 'attachment'
            ORDER BY sentAt ASC;""",
    )


def decrypt_attachment(path: str, local_key: str) -> bytes:
    data = (ATTACHMENTS_DIR / path).read_bytes()
    key_bytes = base64.b64decode(local_key)
    iv, ciphertext, hmac_sig = data[:16], data[16:-32], data[-32:]

    expected = hmac.new(key_bytes[32:], iv + ciphertext, hashlib.sha256).digest()
    if not hmac.compare_digest(expected, hmac_sig):
        raise ValueError(f"HMAC verification failed for {path}")

    dec = Cipher(algorithms.AES(key_bytes[:32]), modes.CBC(iv)).decryptor()
    plaintext = dec.update(ciphertext) + dec.finalize()
    return plaintext[: -plaintext[-1]]


def extension_for(content_type, filename):
    if filename and (ext := Path(filename).suffix):
        return ext
    ext = mimetypes.guess_extension(content_type or "")
    return ".jpg" if ext == ".jpe" else (ext or "")


def unique_path(dest: Path) -> Path:
    if not dest.exists():
        return dest
    stem, suffix = dest.stem, dest.suffix
    counter = 1
    while (candidate := dest.with_name(f"{stem}_{counter}{suffix}")).exists():
        counter += 1
    return candidate


def main():
    parser = argparse.ArgumentParser(
        description="Export media from a Signal Desktop group conversation."
    )
    parser.add_argument("--list-groups", action="store_true")
    parser.add_argument("--group", help="Group name (or substring)")
    parser.add_argument("--output", type=Path, help="Output directory")
    args = parser.parse_args()

    if not args.list_groups and not args.group:
        parser.error("provide --list-groups or --group")

    db_key = get_db_key()
    groups = list_groups(db_key)
    if not groups:
        sys.exit("No groups found.")

    if args.list_groups:
        for g in groups:
            print(g["name"])
        return

    matches = [g for g in groups if args.group.lower() in g["name"].lower()]
    if not matches:
        sys.exit(f"No group matching '{args.group}'")
    if len(matches) > 1:
        sys.exit(f"Ambiguous: {', '.join(m['name'] for m in matches)}")
    group = matches[0]

    output_dir = (args.output or Path(f"./{group['name']}")).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    print(f"Exporting '{group['name']}' to {output_dir}")
    attachments = get_attachments(db_key, group["id"])
    if not attachments:
        print("No media found.")
        return

    exported = skipped = errors = 0
    for att in attachments:
        ext = extension_for(att["contentType"], att["fileName"])
        name = att["fileName"] or (Path(att["path"]).name + ext)
        dest = unique_path(output_dir / name)

        try:
            dest.write_bytes(decrypt_attachment(att["path"], att["localKey"]))
            exported += 1
            print(f"  [{exported}/{len(attachments)}] {dest.name}")
        except FileNotFoundError:
            skipped += 1
        except Exception as e:
            errors += 1
            print(f"  ERROR: {name}: {e}", file=sys.stderr)

    print(f"\nDone: {exported} exported, {skipped} missing, {errors} errors.")


if __name__ == "__main__":
    main()

This only works on Linux with Gnome keyring (or any secretstorage-compatible backend), this code would need to be adapted to work on Windows on macOS, but the only different bit is how to get the secret key in the first place.

Published on 2026-04-09 #python , #signal , #encryption - In code