From ee313415d999b3ddb5357e6d20118e4a830328c5 Mon Sep 17 00:00:00 2001 From: Stephen Beechen Date: Thu, 14 Jul 2022 12:14:39 -0600 Subject: [PATCH] Initial version --- .devcontainer/Dockerfile | 5 + .devcontainer/devcontainer.json | 5 + .devcontainer/requirements.txt | 1 + .gitignore | 1 + .vscode/launch.json | 17 +++ .vscode/settings.json | 3 + README.md | 29 ++++ decrypt-ha-backup/__init__.py | 0 decrypt-ha-backup/__main__.py | 248 ++++++++++++++++++++++++++++++++ pyproject.toml | 3 + scripts/publish.sh | 5 + setup.py | 22 +++ 12 files changed, 339 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/requirements.txt create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 decrypt-ha-backup/__init__.py create mode 100644 decrypt-ha-backup/__main__.py create mode 100644 pyproject.toml create mode 100644 scripts/publish.sh create mode 100644 setup.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..1a0fb1f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.9-buster + +WORKDIR /usr/src/install +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3bae664 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "build": { "dockerfile": "Dockerfile" }, + "extensions": ["ms-python.python","ms-python.vscode-pylance"], + "forwardPorts": [3000] + } \ No newline at end of file diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt new file mode 100644 index 0000000..5577559 --- /dev/null +++ b/.devcontainer/requirements.txt @@ -0,0 +1 @@ +securetar \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6e4761..ac30d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,4 @@ dmypy.json # Pyre type checker .pyre/ +archives/** diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..393d84a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true, + "args": ["full.tar"] + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..79649a2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "jupyter.debugJustMyCode": false +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f5acbc --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +## What is this? +This is a command-line python module that allows you to turn an encrypted Home Assistant backup (aka "Password Protected") into a non-encrypted backup. You might find this useful in situations such as: +- Your backup has been corrupted and you're just trying to get what you can out of it. +- You're trying to get just one or two files out of a backup without having to restore the whole thing. + +Home Assistant backups are just compressed tar files but to encrypt them with a password it uses a non-standard encryption scheme. To the author's knowledge there is not way to decrypt these with standard compression/decompression tools which is why he wrote this little utility. + +## A note on reliability and expectations +This tool isn't sanctioned by the developers of Home Assistant and isn't updated in response to changes Home Assistant makes to the format of its backup files. This tool hacks apart a backup and then builds it back up, which makes it very sensitive to any changes the Home Assistant developers make to the backup file format. + +It has been tested on backups created by Home Assistant version 2022.6.7. If you encounter an error using this tool please consider creating an issue for it on GitHub to notify the maintainer, you'll probably be helping many other users if you bring attention to an issue. + +## Installation +Make sure you have python 3 and pip installed on your system. Search around on Google for how to install them on your operating system. Thenf rom the command line: +```bash +pip install decrypt-ha-backup +``` + +## Usage +Download your backup from Home Assistant. Ensure you have at least twice the free space on your hard drive available, and run: +```bash +python3 -m decrypt-ha-backup /path/to/your/backup.tar +``` + +You will be asked for the backups' password, after being processed the decrypted backup will be placed at ```/path/to/your/Decrypted backup.tar```. + +### Optional Arguments +- --password secret_password Pass in the password +- --output_filename /path/to/output.tar Specify the output file name \ No newline at end of file diff --git a/decrypt-ha-backup/__init__.py b/decrypt-ha-backup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/decrypt-ha-backup/__main__.py b/decrypt-ha-backup/__main__.py new file mode 100644 index 0000000..5191838 --- /dev/null +++ b/decrypt-ha-backup/__main__.py @@ -0,0 +1,248 @@ +import argparse +from io import BytesIO +import string +import sys +import hashlib +import tarfile +import json +import os +import random +import getpass +from typing import IO +import securetar +import tempfile +import platform +from pathlib import Path + +#PATH = "EncryptedFolders.tar" +PATH = "EncryptedFolders.tar" +PASSWORD = "orcsorcs" + +def password_to_key(password: str) -> bytes: + """Generate a AES Key from password.""" + key: bytes = password.encode() + for _ in range(100): + key = hashlib.sha256(key).digest() + return key[:16] + +def key_to_iv(key: bytes) -> bytes: + """Generate an iv from Key.""" + for _ in range(100): + key = hashlib.sha256(key).digest() + return key[:16] + +def _generate_iv(key: bytes, salt: bytes) -> bytes: + """Generate an iv from data.""" + temp_iv = key + salt + for _ in range(100): + temp_iv = hashlib.sha256(temp_iv).digest() + return temp_iv[:16] + +def overwrite(line: str): + sys.stdout.write(f"\r{line}\033[K") + +def readTarMembers(tar: tarfile.TarFile): + while(True): + member = tar.next() + if member is None: + break + else: + yield member + +class Backup: + def __init__(self, tarfile: tarfile.TarFile): + self._tarfile = tarfile + try: + self._configMember = self._tarfile.getmember("./snapshot.json") + except KeyError: + self._configMember = self._tarfile.getmember("./backup.json") + json_file = self._tarfile.extractfile(self._configMember) + self._config = json.loads(json_file.read()) + json_file.close() + self._items = [BackupItem(entry['slug'], entry['name'], self) for entry in self._config.get("addons")] + self._items += [BackupItem(entry, self.folderSlugToName(entry), self) for entry in self._config.get("folders")] + + if self._config.get('homeassistant') is not None: + self._items.append(BackupItem('homeassistant', self.folderSlugToName('homeassistant'), self)) + + + def folderSlugToName(self, slug): + if slug == "homeassistant": + return "Config Folder" + elif slug == "addons/local": + return "Local Add-ons" + elif slug == "media": + return "Media Folder" + elif slug == "share": + return "Share Folder" + elif slug == "ssl": + return "SSL Folder" + else: + return slug + + @property + def compressed(self): + return self._config.get("compressed", True) + + @property + def encrypted(self): + return self._config.get('protected', False) + + @property + def items(self): + return self._items + + @property + def version(self): + return self._config.get('version') + + def create_slug(self) -> str: + key = ''.join(random.choice(string.ascii_uppercase) for _ in range(50)).encode() + return hashlib.sha1(key).hexdigest()[:8] + + def addModifiedConfig(self, tarfile: tarfile.TarFile): + clear = self._config.copy() + clear['crypto'] = None + clear['protected'] = False + clear['slug'] = self.create_slug() + clear['name'] = "Decrypted " + clear['name'] + bytes = json.dumps(clear, indent=2).encode('utf-8') + file = BytesIO(bytes) + self._configMember.size = len(bytes) + tarfile.addfile(self._configMember, file) + + +class BackupItem: + def __init__(self, slug, name, backup: Backup): + self._slug = slug + self._name = name + self._backup = backup + self._info = self._backup._tarfile.getmember(self.fileName) + + @property + def fileName(self): + ext = ".tar.gz" if self._backup.compressed else ".tar" + return f"./{self._slug.replace('/', '_')}{ext}" + + @property + def slug(self): + return self._slug + + @property + def name(self): + return self._name + + @property + def info(self) -> tarfile.TarInfo: + return self._info + + @property + def size(self): + return self.info.size + + def _open(self): + return self._backup._tarfile.extractfile(self.info) + + def _extractTo(self, file: IO): + progress = 0 + encrypted = self._open() + overwrite(f"Extracting '{self.name}' 0%") + while(True): + data = encrypted.read(1024 * 1024) + if len(data) == 0: + break + file.write(data) + overwrite(f"Extracting '{self.name}' {round(100 * progress/self.size, 1)}%") + progress += len(data) + file.flush() + overwrite(f"Extracting '{self.name}' {round(100 * progress/self.size, 1)}%") + file.seek(0) + print() + + def _copyTar(self, source: tarfile.TarFile, dest: tarfile.TarFile): + for member in readTarMembers(source): + overwrite(f"Decrypting '{self.name}' file '{member.name}'") + if not tarfile.TarInfo.isreg(member): + dest.addfile(member) + else: + dest.addfile(member, source.extractfile(member)) + + def addTo(self, output: tarfile, key: bytes): + with tempfile.NamedTemporaryFile() as extracted: + self._extractTo(extracted) + overwrite(f"Decrypting '{self.name}'") + extracted.seek(0) + with securetar.SecureTarFile(Path(extracted.name), "r", key=key, gzip=self._backup.compressed) as decrypted: + with tempfile.NamedTemporaryFile() as processed: + tarmode = "w|" + ("gz" if self._backup.compressed else "") + with tarfile.open(f"{self.slug}.tar", tarmode, fileobj=processed) as archivetar: + self._copyTar(decrypted, archivetar) + processed.flush() + overwrite(f"Decrypting '{self.name}' done") + print() + info = self.info + info.size = os.stat(processed.name).st_size + processed.seek(0) + overwrite(f"Saving '{self.name}' ...") + output.addfile(info, processed) + overwrite(f"Saving '{self.name}' done") + print() + + +def main(): + parser = argparse.ArgumentParser(description="Decrypts an encrypted Home Assistant backup file", prog="decrypt_ha_backup") + parser.add_argument("backup_file", help='The backup file that should be decrypted') + parser.add_argument("--output_file", "-o", help='The name of decrypted backup file to be created. If not specified, it will be chosen based on the backup name.') + parser.add_argument("--password", "-p", "--pass", help="The password for the backup. If not specified, you will be prompted for it.") + args = parser.parse_args() + + if not os.path.exists(args.backup_file): + print("The specified backup file couldn't be found") + exit() + + if args.output_file is None: + parts = list(Path(args.backup_file).parts) + parts[-1] = "Decrypted " + parts[-1] + args.output_file = os.path.join(*parts) + + if os.path.exists(args.output_file): + resp = input(f"The output file '{args.output_file}' already exists, do you want to overwrite it [y/n]?") + if not resp.startswith("y"): + print("Aborted") + exit() + + if args.password is None: + # ask fro a password + args.password = getpass.getpass("Backup Password:") + + try: + with tarfile.open(Path(args.backup_file), "r:") as backup_file: + backup = Backup(backup_file) + if not backup.encrypted: + print("This backup file isn't encrypted") + return + if backup.version != 2: + print(f"Only backup format 'Version 2' is supported, this backup is 'Version {backup.version}'") + return + + _key = password_to_key(args.password) + + with tarfile.open(args.output_file, "w:") as output: + for archive in backup.items: + archive.addTo(output, _key) + + # Add the modified backup config + backup.addModifiedConfig(output) + + print(f"Created backup file '{args.backup_file}'") + except tarfile.ReadError as e: + if "not a gzip file" in str(e): + print("The file could not be read as a gzip file. Please ensure your password is correct.") + + +if __name__ == '__main__': + if platform.system() == 'Windows': + from ctypes import windll + windll.kernel32.SetConsoleMode(windll.kernel32.GetStdHandle(-11), 7) + + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa7093a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100644 index 0000000..ac68b5b --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,5 @@ +pip install -q --upgrade setupext-janitor twine build +python3 setup.py clean --dist --eggs +python3 -m build +keyring --disable +python3 -m twine upload dist/* \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6c920d4 --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +from setuptools import find_packages, setup + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name='decrypt-ha-backup', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.9', + ], + packages=find_packages(include=['decrypt-ha-backup']), + version='2022.7.14.4', + description='Decryption utility for Home Assistant backups', + long_description=long_description, + long_description_content_type="text/markdown", + install_requires=["securetar"], + author="Stephen Beechen", + author_email="stephen@beechens.com", + python_requires=">=3.9", +) \ No newline at end of file