242 lines
9.6 KiB
Python
Executable file
242 lines
9.6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
import argparse
|
|
import getpass
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
|
|
SIGNAL_BUNDLEID = "org.whispersystems.signal"
|
|
SIGNAL_APPGROUP = "group.org.whispersystems.signal.group"
|
|
SIGNAL_APPGROUP_STAGING = "group.org.whispersystems.signal.group.staging"
|
|
|
|
SIGNAL_DEBUG_PAYLOAD_NAME = "dbPayload.txt"
|
|
SIGNAL_DEBUG_PAYLOAD_PASSPHRASE_KEY = "key"
|
|
SIGNAL_FALLBACK_DATABASE_PATH = "grdb/signal.sqlite"
|
|
|
|
DB_BROWSER_FOR_SQLITE_BUNDLEID = "net.sourceforge.sqlitebrowser"
|
|
|
|
quietMode=False
|
|
def failWithError(string):
|
|
print("Error: " + string, file=sys.stderr)
|
|
exit(1)
|
|
|
|
def printInfo(string = ""):
|
|
if quietMode == False:
|
|
print(string)
|
|
|
|
def runCommand(cmdList):
|
|
result = subprocess.run(cmdList, text=True, capture_output=True)
|
|
if result.returncode != 0:
|
|
failWithError("Failed to run \"" + " ".join(cmdList) + "\". Status: " + str(result.returncode) + "\n" + result.stderr)
|
|
return result.stdout
|
|
|
|
|
|
class Simulator:
|
|
def __init__(self, searchString, useStaging):
|
|
|
|
# Get JSON list of simulators matching searchString
|
|
cmd = "xcrun simctl list -j devices " + searchString
|
|
resultString = runCommand(cmd.split())
|
|
simDict = json.loads(resultString)
|
|
devicesByRuntime = simDict["devices"]
|
|
|
|
# Parse all candidates
|
|
candidates = []
|
|
for runtime, devices in devicesByRuntime.items():
|
|
os = self.parseOSFromRuntime(runtime)
|
|
for device in devices:
|
|
udid = device.get("udid")
|
|
rawDevice = device.get("deviceTypeIdentifier")
|
|
name = device.get("name")
|
|
if udid != None:
|
|
deviceType = self.parseDeviceTypeFromRaw(rawDevice)
|
|
candidates.append({"os": os, "type": deviceType, "udid": udid, "name": name})
|
|
|
|
# Select a candidate
|
|
selectedCandidate = None
|
|
|
|
if len(candidates) == 0:
|
|
failWithError("Could not find a \"" + searchString + "\" simulator")
|
|
elif len(candidates) == 1:
|
|
selectedCandidate = candidates[0]
|
|
else:
|
|
if quietMode:
|
|
failWithError("Multiple simulator candidates. Interactive selection not supported in quiet mode")
|
|
for idx, candidate in enumerate(candidates):
|
|
printInfo("{}:\t{:40}\t{} {} ({})".format(idx, candidate["name"], candidate["type"], candidate["os"], candidate["udid"]))
|
|
|
|
while selectedCandidate == None:
|
|
try:
|
|
idx = int(input("Select a simulator: "))
|
|
selectedCandidate = candidates[idx]
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
self.udid = selectedCandidate["udid"]
|
|
self.groupID = SIGNAL_APPGROUP_STAGING if useStaging else SIGNAL_APPGROUP
|
|
self.groupContainer = self.fetchGroupContainer(self.udid, self.groupID)
|
|
printInfo("Selected simulator: " + selectedCandidate["name"] + " (" + selectedCandidate["udid"] + ")")
|
|
printInfo("Using groupID: " + self.groupID)
|
|
printInfo()
|
|
|
|
def parseDebugPayload(self):
|
|
path = self.groupContainer + "/" + SIGNAL_DEBUG_PAYLOAD_NAME
|
|
try:
|
|
fd = open(path, 'r')
|
|
data = fd.read()
|
|
payload = json.loads(data)
|
|
return payload
|
|
except IOError:
|
|
return None
|
|
|
|
def databasePath(self):
|
|
return (self.groupContainer + "/" + SIGNAL_FALLBACK_DATABASE_PATH)
|
|
|
|
def passphraseIfAvailable(self):
|
|
debugPayload = self.parseDebugPayload()
|
|
if debugPayload and SIGNAL_DEBUG_PAYLOAD_PASSPHRASE_KEY in debugPayload:
|
|
return debugPayload[SIGNAL_DEBUG_PAYLOAD_PASSPHRASE_KEY]
|
|
else:
|
|
return None
|
|
|
|
@staticmethod
|
|
def parseOSFromRuntime(runtime):
|
|
lastPeriodIdx = runtime.rfind('.')
|
|
hypenatedOS = runtime[lastPeriodIdx+1:]
|
|
return hypenatedOS.replace("-", ".")
|
|
|
|
@staticmethod
|
|
def parseDeviceTypeFromRaw(rawDevice):
|
|
lastPeriodIdx = rawDevice.rfind('.')
|
|
hypenatedOS = rawDevice[lastPeriodIdx+1:]
|
|
return hypenatedOS.replace("-", " ")
|
|
|
|
@staticmethod
|
|
def fetchGroupContainer(udid, groupID):
|
|
cmd = "xcrun simctl get_app_container {} {} {}".format(udid, SIGNAL_BUNDLEID, groupID)
|
|
result = runCommand(cmd.split())
|
|
return result.rstrip()
|
|
|
|
def preparePassphrase(passphrase):
|
|
if len(passphrase) > 0 and passphrase[0] == 'x':
|
|
return passphrase
|
|
else:
|
|
return "x'" + passphrase + "'"
|
|
|
|
def writeGuiEnvFile(passphrase, dbPath):
|
|
dbName = os.path.basename(dbPath)
|
|
envFilePath = os.path.join(os.path.dirname(dbPath), ".env")
|
|
|
|
with open(envFilePath, "w", encoding="utf-8") as envFile:
|
|
envFile.write(dbName + " = " + passphrase + "\n")
|
|
envFile.write(dbName + "_plaintextHeaderSize = 32\n")
|
|
|
|
return envFilePath
|
|
|
|
parser = argparse.ArgumentParser(
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
description=textwrap.dedent('''\
|
|
SQLCipher Command Line Interface
|
|
|
|
If providing a simulatorID (or accepting the default "Booted" simulator), passphrase retrieval
|
|
can be simplified by navigating to Signal Settings > Debug UI > Misc > Save plaintext database key.
|
|
If a database key could not be found and one was not provided through an argument, you'll be prompted
|
|
to enter one.
|
|
|
|
Alternatively, you can provide a sqlcipher path directly via command line arguments. In this case,
|
|
you'll be required to provide a database key through an argument or stdin.
|
|
|
|
If --use-gui is specified, this script will attempt to open the database using the "DB Browser for
|
|
SQLite" (DBBfS) application.
|
|
|
|
If --gui-auto-decrypt-with-plaintext-key is passed alongside --use-gui, the script will place the
|
|
passphrase in a file next to the database file such that DBBfS is able to automatically decrypt and
|
|
open the databse. Note that this file is in plaintext, and *ONLY USE* with databases containing
|
|
test data.
|
|
'''),
|
|
usage="%(prog)s [--simulator simID [--staging] | --path dbPath] [--passphrase passphrase] [--quiet] [--use-gui [--gui-auto-decrypt-with-plaintext-key]]")
|
|
|
|
group = parser.add_mutually_exclusive_group()
|
|
group.add_argument("--simulator", metavar="SIM", help="A string identifiying a simulator instance. (default: %(default)s).", default="booted")
|
|
group.add_argument("--path", help="Path to a sqlcipher DB")
|
|
parser.add_argument("--passphrase", metavar="PASS", help="The passphrase encrypting the database")
|
|
parser.add_argument("--staging", action='store_true', help="If a simulator is being targeted, specifies that the staging database should be used")
|
|
parser.add_argument("remainder", nargs=argparse.REMAINDER, metavar="--", help="All subsequent args will be interpreted as SQL. You probably want quotes here. Be careful with \"*\" since your shell will probably replace it. Ignored if using GUI")
|
|
parser.add_argument("--quiet", action='store_true', help="Suppress non-failing output")
|
|
parser.add_argument(
|
|
"--use-gui",
|
|
action='store_true',
|
|
help="Tells the script to try and open DB Browser for SQLite"
|
|
)
|
|
parser.add_argument(
|
|
"--gui-auto-decrypt-with-plaintext-key",
|
|
action='store_true',
|
|
help=(
|
|
"Tells the script to try and have DB Browser for SQLite auto-decrypt the database by "
|
|
"placing the key in plaintext next to the DB file. ONLY USE with DBs guaranteed to "
|
|
"only contain test data"
|
|
)
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
quietMode=args.quiet
|
|
dbPath = None
|
|
passphrase = None
|
|
|
|
if args.path:
|
|
dbPath = args.path
|
|
elif args.simulator:
|
|
target = Simulator(args.simulator, args.staging)
|
|
dbPath = target.databasePath()
|
|
passphrase = target.passphraseIfAvailable()
|
|
|
|
if dbPath == None:
|
|
failWithError("No valid database path")
|
|
elif os.path.isfile(dbPath) == False:
|
|
failWithError("Not valid path " + dbPath)
|
|
|
|
if args.passphrase:
|
|
passphrase = args.passphrase
|
|
if passphrase == None:
|
|
passphrase = getpass.getpass("Please enter the passphrase. Alternatively, set up a plaintext database key in Debug UI > Misc > Save plaintext database key. Then, rerun the command. ")
|
|
|
|
if args.use_gui:
|
|
if args.gui_auto_decrypt_with_plaintext_key:
|
|
if passphrase == None or len(passphrase) == 0:
|
|
failWithError("Missing sqlcipher passphrase for auto-decryption")
|
|
|
|
passphrase = preparePassphrase(passphrase)
|
|
envFilePath = writeGuiEnvFile(passphrase, dbPath)
|
|
|
|
printInfo("Warning: saved passphrase to " + envFilePath + " for auto-decryption.")
|
|
else:
|
|
printInfo(textwrap.dedent('''\
|
|
When prompted for the passphrase, select the SQLCipher 4 default settings.
|
|
Then, select "Custom" and set the "Plaintext Header Size" to 32 from 0.
|
|
Finally, select "Raw Key" instead of "Passphrase", manually enter "0x", and paste the key.
|
|
'''))
|
|
|
|
runCommand(["open", "-b", DB_BROWSER_FOR_SQLITE_BUNDLEID, dbPath])
|
|
else:
|
|
if passphrase == None or len(passphrase) == 0:
|
|
failWithError("No valid sqlcipher passphrase")
|
|
|
|
passphrase = preparePassphrase(passphrase)
|
|
|
|
sqlArgs = args.remainder
|
|
if len(sqlArgs) > 0 and sqlArgs[0] == "--":
|
|
sqlArgs.pop(0)
|
|
sqlArgString = " ".join(sqlArgs)
|
|
|
|
allArgs = [
|
|
"sqlcipher",
|
|
"-cmd", "PRAGMA key = \"" + passphrase + "\";",
|
|
"-cmd", "PRAGMA cipher_plaintext_header_size = 32;",
|
|
dbPath
|
|
]
|
|
if len(sqlArgString) > 0:
|
|
allArgs.append(sqlArgString)
|
|
|
|
os.execvp("sqlcipher", allArgs)
|