Skip to content

Commit

Permalink
Initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
sabeechen committed Jul 14, 2022
1 parent 536a6f6 commit ee31341
Show file tree
Hide file tree
Showing 12 changed files with 339 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .devcontainer/Dockerfile
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
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer.json
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]
}
1 change: 1 addition & 0 deletions .devcontainer/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
securetar
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,4 @@ dmypy.json

# Pyre type checker
.pyre/
archives/**
17 changes: 17 additions & 0 deletions .vscode/launch.json
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"]
}
]
}
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"jupyter.debugJustMyCode": false
}
29 changes: 29 additions & 0 deletions README.md
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 added decrypt-ha-backup/__init__.py
Empty file.
248 changes: 248 additions & 0 deletions decrypt-ha-backup/__main__.py
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()
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
5 changes: 5 additions & 0 deletions scripts/publish.sh
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/*
22 changes: 22 additions & 0 deletions setup.py
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",
)

0 comments on commit ee31341

Please sign in to comment.