#!/usr/bin/env python3 import argparse import base64 import datetime as dt import hashlib import hmac import json import sys import time from pathlib import Path DEFAULT_FILE = "token.json" def base32_decode(secret: str) -> bytes: secret = secret.strip().replace(" ", "").upper() secret += "=" * (-len(secret) % 8) return base64.b32decode(secret, casefold=True) def generate_otp(secret: str, counter: int, digits: int, algorithm: str) -> str: algorithms = { "SHA1": hashlib.sha1, "SHA256": hashlib.sha256, "SHA512": hashlib.sha512, } algorithm = algorithm.upper() if algorithm not in algorithms: raise ValueError(f"Nicht unterstützter Algorithmus: {algorithm}") key = base32_decode(secret) message = counter.to_bytes(8, byteorder="big") digest = hmac.new(key, message, algorithms[algorithm]).digest() offset = digest[-1] & 0x0F value = int.from_bytes(digest[offset:offset + 4], "big") & 0x7FFFFFFF return str(value % (10 ** digits)).zfill(digits) def load_entries(path: Path) -> list[dict]: with path.open("r", encoding="utf-8") as f: data = json.load(f) entries = data.get("db", {}).get("entries") or data.get("entries") if not isinstance(entries, list): raise ValueError( "Keine Aegis-Einträge gefunden. Die Datei muss ein " "unverschlüsselter Aegis-JSON-Export mit db.entries sein." ) return entries def get_entry_label(entry: dict) -> str: issuer = str(entry.get("issuer") or "").strip() name = str(entry.get("name") or "").strip() if issuer and name: return f"{issuer}: {name}" return issuer or name or "(ohne Namen)" def get_algorithm(info: dict) -> str: # Aegis nutzt "algo"; die Alternative dient der Kompatibilität. return str(info.get("algo") or info.get("algorithm") or "SHA1") def table(rows: list[list[str]], headers: list[str]) -> str: widths = [ max(len(headers[i]), *(len(row[i]) for row in rows)) for i in range(len(headers)) ] def separator() -> str: return "+-" + "-+-".join("-" * width for width in widths) + "-+" def format_row(row: list[str]) -> str: return "| " + " | ".join( value.ljust(widths[i]) for i, value in enumerate(row) ) + " |" output = [ separator(), format_row(headers), separator(), ] output.extend(format_row(row) for row in rows) output.append(separator()) return "\n".join(output) def generate_row( entry: dict, now: int, forced_counter: int | None, ) -> tuple[list[str], str]: """Erzeugt eine Tabellenzeile und gibt zusätzlich den Tokentyp zurück. Die Zeile enthält: Label, Current-OTP, Next-OTP, übernächster-OTP. Bei TOTP sind die drei OTPs die Codes für das aktuelle, das nächste und das übernächste Zeitfenster. Bei HOTP werden counter, counter+1 und counter+2 verwendet. """ info = entry.get("info", {}) token_type = str(entry.get("type") or info.get("type") or "totp").lower() secret = info.get("secret") if not secret: return ( [ get_entry_label(entry), "FEHLER: kein Secret", "-", "-", ], token_type, ) try: digits = int(info.get("digits", 6)) algorithm = get_algorithm(info) if token_type == "totp": period = int(info.get("period", 30)) counter = now // period code0 = generate_otp(secret, counter, digits, algorithm) code1 = generate_otp(secret, counter + 1, digits, algorithm) code2 = generate_otp(secret, counter + 2, digits, algorithm) return ( [get_entry_label(entry), code0, code1, code2], token_type, ) elif token_type == "hotp": counter = ( forced_counter if forced_counter is not None else int(info.get("counter", 0)) ) code0 = generate_otp(secret, counter, digits, algorithm) code1 = generate_otp(secret, counter + 1, digits, algorithm) code2 = generate_otp(secret, counter + 2, digits, algorithm) return ( [get_entry_label(entry), code0, code1, code2], token_type, ) else: return ( [ get_entry_label(entry), "ÜBERSPRUNGEN", "-", "-", ], token_type, ) except (ValueError, TypeError, base64.binascii.Error) as exc: return ( [ get_entry_label(entry), f"FEHLER: {exc}", "-", "-", ], token_type, ) def build_headers(entries: list[dict], now: int) -> list[str]: """Spaltenüberschriften mit verbleibenden Sekunden je Zeitfenster. Berücksichtigt nur TOTP-Einträge, um die Periode zu bestimmen. Sind mehrere Perioden vorhanden, wird die kleinste Periode für die Überschriften verwendet (typischerweise 30 s). HOTP-Only-Listen erhalten generische Überschriften. """ periods = [] for entry in entries: info = entry.get("info", {}) token_type = str(entry.get("type") or info.get("type") or "totp").lower() if token_type == "totp": periods.append(int(info.get("period", 30))) if not periods: # Keine TOTP-Einträge: generische Überschriften. return ["Account", "Current", "Next", "übernächster"] period = min(periods) remaining = period - (now % period) return [ "Account", f"{remaining}s", f"{remaining + period}s", f"{remaining + 2 * period}s", ] def main() -> None: parser = argparse.ArgumentParser( description="TOTP/HOTP aus einem unverschlüsselten Aegis-Export erzeugen" ) parser.add_argument( "file", nargs="?", default=DEFAULT_FILE, help=f"Aegis-JSON-Datei; Standard: {DEFAULT_FILE}", ) parser.add_argument( "-s", "--search", help="Filter für Issuer oder Name, z. B. Sophos", ) parser.add_argument( "--otp-only", action="store_true", help="Nur den OTP eines eindeutig gefundenen Eintrags ausgeben", ) parser.add_argument( "--at", type=int, metavar="UNIXTIME", help="TOTP für einen bestimmten Unix-Zeitstempel erzeugen", ) parser.add_argument( "--counter", type=int, help="HOTP-Zähler überschreiben; verändert die JSON-Datei nicht", ) args = parser.parse_args() path = Path(args.file).expanduser() if not path.is_file(): raise FileNotFoundError(f"Datei nicht gefunden: {path}") entries = load_entries(path) if args.search: needle = args.search.casefold() entries = [ entry for entry in entries if needle in get_entry_label(entry).casefold() ] if not entries: raise ValueError("Keine passenden Token gefunden.") now = args.at if args.at is not None else int(time.time()) # Für Automatisierung: ausschließlich die OTP-Ziffern ausgeben. if args.otp_only: if len(entries) != 1: raise ValueError( "--otp-only benötigt genau einen Treffer. " "Bitte --search eindeutiger wählen." ) row, _ = generate_row(entries[0], now, args.counter) code = row[1] # Current-OTP if not code.isdigit(): raise ValueError(f"OTP konnte nicht erzeugt werden: {code}") print(code) return rows = [generate_row(entry, now, args.counter)[0] for entry in entries] rows.sort(key=lambda row: row[0].casefold()) headers = build_headers(entries, now) print(table(rows, headers)) if args.at is None: timestamp = dt.datetime.now().astimezone().isoformat(timespec="seconds") print(f"\nZeit: {timestamp}") else: timestamp = dt.datetime.fromtimestamp(args.at).astimezone().isoformat( timespec="seconds" ) print(f"\nZeit (--at): {timestamp}") if __name__ == "__main__": try: main() except ( OSError, ValueError, json.JSONDecodeError, base64.binascii.Error, ) as exc: print(f"Fehler: {exc}", file=sys.stderr) sys.exit(1)