15.1.932. tablet_qt/tools/open_sqlcipher.py

#!/usr/bin/env python

"""
tools/open_sqlcipher.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

Tool to open an encrypted database via the SQLCipher command line tool.
"""

import argparse
import getpass
import logging
import os
import shutil
import sys

from cardinal_pythonlib.logs import (
    BraceStyleAdapter,
    main_only_quicksetup_rootlogger,
)
import pexpect
from rich_argparse import ArgumentDefaultsRichHelpFormatter

log = BraceStyleAdapter(logging.getLogger(__name__))

EXIT_FAIL = 1
PASSWORD_ENV_VAR = "DECRYPT_SQLCIPHER_PASSWORD"
SQLCIPHER_ENV_VAR = "SQLCIPHER"
SQLCIPHER_DEFAULT = "sqlcipher"


def string_to_sql_literal(s: str) -> str:
    return "'{}'".format(s.replace("'", "''"))


def main() -> None:
    # -------------------------------------------------------------------------
    # Logging
    # -------------------------------------------------------------------------
    logging.basicConfig(level=logging.DEBUG)

    # -------------------------------------------------------------------------
    # Command-line arguments
    # -------------------------------------------------------------------------
    parser = argparse.ArgumentParser(
        description="Open an encrypted database at the SQLCipher command line",
        formatter_class=ArgumentDefaultsRichHelpFormatter,
    )
    parser.add_argument(
        "encrypted", help="Filename of the existing encrypted database"
    )
    parser.add_argument(
        "--password",
        type=str,
        default=None,
        help="Password (if blank, environment variable {} will be used, or you"
        " will be prompted)".format(PASSWORD_ENV_VAR),
    )
    parser.add_argument(
        "--sqlcipher",
        type=str,
        default=None,
        help=(
            "SQLCipher executable file (if blank, environment variable {} "
            "will be used, or the default of {})"
        ).format(SQLCIPHER_ENV_VAR, repr(SQLCIPHER_DEFAULT)),
    )
    parser.add_argument(
        "--cipher_compatibility",
        type=int,
        default=None,
        help=(
            "Use compatibility settings for this major version of SQLCipher "
            "(e.g. 3)"
        ),
    )
    parser.add_argument(
        "--cipher_migrate",
        action="store_true",
        help="Migrate the database to the latest version of SQLCipher",
    )
    parser.add_argument(
        "--encoding",
        type=str,
        default=sys.getdefaultencoding(),
        help="Encoding to use",
    )
    progargs = parser.parse_args()
    # log.debug("Args: " + repr(progargs))

    assert not (
        progargs.cipher_migrate and progargs.cipher_compatibility is not None
    ), "Can't specify both --cipher_migrate and --cipher_compatibility"

    # -------------------------------------------------------------------------
    # SQLCipher executable
    # -------------------------------------------------------------------------
    sqlcipher = (
        progargs.sqlcipher
        or os.environ.get(SQLCIPHER_ENV_VAR)
        or SQLCIPHER_DEFAULT
    )

    # -------------------------------------------------------------------------
    # Check file existence
    # -------------------------------------------------------------------------
    if not os.path.isfile(progargs.encrypted):
        log.critical("No such file: {!r}", progargs.encrypted)
        sys.exit(EXIT_FAIL)
    if not shutil.which(sqlcipher):
        log.critical("Can't find SQLCipher at {!r}", sqlcipher)
        sys.exit(EXIT_FAIL)

    # -------------------------------------------------------------------------
    # Password
    # -------------------------------------------------------------------------
    log.warning("Password will be visible on SQLCipher command line")
    password = progargs.password
    if password:
        log.debug(
            "Using password from command-line arguments (NB danger: "
            "visible to ps and similar tools)"
        )
    elif PASSWORD_ENV_VAR in os.environ:
        password = os.environ[PASSWORD_ENV_VAR]
        log.debug(
            "Using password from environment variable {}", PASSWORD_ENV_VAR
        )
    else:
        log.info(
            "Password not on command-line or in environment variable {};"
            " please enter it manually.",
            PASSWORD_ENV_VAR,
        )
        password = getpass.getpass()

    # -------------------------------------------------------------------------
    # Run SQLCipher to do the work
    # -------------------------------------------------------------------------
    sql_commands = [
        '-- Note that ".bail" does nothing in interactive mode.',
        "-- Show SQLCipher executable version",
        "PRAGMA cipher_version;",
        "-- Gain access to the encrypted database",
        "PRAGMA key = {key};".format(key=string_to_sql_literal(password)),
    ]
    if progargs.cipher_compatibility is not None:
        sql_commands += [
            "-- Set compatibility to a specific version of SQLCipher",
            "PRAGMA cipher_compatibility = {};".format(
                progargs.cipher_compatibility
            ),
        ]
    if progargs.cipher_migrate:
        # This command takes several seconds (e.g. 3.4 s) if it has to do work,
        # but it's fairly quick, e.g. 260 ms, if it doesn't.
        timestring = "%Y-%m-%d %H:%M:%f"
        sql_commands += [
            "-- Migrate from a previous version of SQLCipher",
            "SELECT strftime('{}', 'now');".format(timestring),
            "PRAGMA cipher_migrate;",
            "SELECT strftime('{}', 'now');".format(timestring),
        ]
    sql_commands += [
        "-- Check we can read from old database "
        "(or quit without creating new one)",
        "SELECT COUNT(*) FROM sqlite_master;",
        "-- If there was an error, the password was wrong.",
        '-- If no error: access achieved! Try e.g. ".tables" to list tables.',
    ]
    log.info("Calling SQLCipher ({!r})...", sqlcipher)
    child = pexpect.spawn(sqlcipher, [progargs.encrypted])
    log.debug("Spawned")
    for line in sql_commands:
        child.sendline(line)
    child.interact()
    log.debug("Done")


if __name__ == "__main__":
    main_only_quicksetup_rootlogger(level=logging.DEBUG)
    main()