-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
12 changed files
with
339 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"build": { "dockerfile": "Dockerfile" }, | ||
"extensions": ["ms-python.python","ms-python.vscode-pylance"], | ||
"forwardPorts": [3000] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
securetar |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -127,3 +127,4 @@ dmypy.json | |
|
||
# Pyre type checker | ||
.pyre/ | ||
archives/** |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"jupyter.debugJustMyCode": false | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
- <kbd>--password secret_password</kbd> Pass in the password | ||
- <kbd>--output_filename /path/to/output.tar</kbd> Specify the output file name |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
[build-system] | ||
requires = ["setuptools>=42"] | ||
build-backend = "setuptools.build_meta" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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/* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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="[email protected]", | ||
python_requires=">=3.9", | ||
) |