# read_qlbak.py: open a Quietlift .qlbak (format v1 or v2) with your passphrase. # Needs Python 3.8+ and the "cryptography" package (pip install cryptography). import getpass, hashlib, io, json, struct, sys, zipfile from cryptography.hazmat.primitives.ciphers.aead import AESGCM def read_header(data): if data[:6] != b"QLBAK\x00": raise ValueError("not a .qlbak file") version = data[6] if version not in (1, 2): raise ValueError(f"format version {version}; this reader knows versions 1 and 2") (iterations,) = struct.unpack(">I", data[7:11]) # big-endian if not 1 <= iterations <= 10_000_000: raise ValueError("implausible iteration count") salt = data[11:27] # 16 bytes end, hint = 27, "" if version == 2: (size,) = struct.unpack(">H", data[27:29]) # hint length, big-endian end = 29 + size if end + 12 + 16 > len(data): raise ValueError("hint length runs past the file") hint = data[29:end].decode("utf-8") return iterations, salt, hint, end def open_qlbak(path, passphrase): data = open(path, "rb").read() iterations, salt, hint, end = read_header(data) header = data[:end] # authenticated, not encrypted nonce = data[end:end + 12] # 12 bytes sealed = data[end + 12:] # ciphertext followed by the 16-byte tag key = hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32) plain = AESGCM(key).decrypt(nonce, sealed, header) # raises InvalidTag on a wrong passphrase members = zipfile.ZipFile(io.BytesIO(plain)) manifest = json.loads(members.read("manifest.json")) for m in manifest["members"]: body = members.read(m["name"]) if hashlib.sha256(body).hexdigest() != m["sha256"]: raise ValueError(f"{m['name']} does not match its hash") export = json.loads(members.read("export.json")) return manifest, export if __name__ == "__main__": hint = read_header(open(sys.argv[1], "rb").read())[2] if hint: print("Hint:", hint) manifest, export = open_qlbak(sys.argv[1], getpass.getpass("Passphrase: ")) print(json.dumps(manifest, indent=2)) print(len(export["data"]["workouts"]), "workouts")