Welcher Informatiker kennt es nicht: Man müsste sich noch kurz irgendwo einloggen, hat auch den JSON Export der Aegis OTP Software in wohldefinierten Ordnern auf der Festplatte abgelegt. Aber leider hat man gerade sein Handy nicht zur Hand.
Da kann man sich doch mal eben von der KI ein Kommandozeilentool schreiben, welches die aktuellen OTP berechnet und per Kommandozeile ausgibt. Schneller als 'das Handy holen' allemal.
christian# ./aegis.py ./Dokumente/aegis-export.json
+-------------------------------------------+------+--------+------------+
| Account | Typ | OTP | Gültigkeit |
+-------------------------------------------+------+--------+------------+
| CCP: 343330 | TOTP | 554007 | 8s |
| DATEV-Konto: info@xxxx | TOTP | 000079 | 8s |
| Discord: info@xxxx | TOTP | 130099 | 8s |
| PayPal: info@xxxx | TOTP | 763005 | 8s |
| Proxmox Mail Gateway - root@pam | TOTP | 500567 | 8s |
| Ring.com: info@xxxx | TOTP | 720062 | 8s |
| Sophos SFOS: support@xxxx | TOTP | 300110 | 8s |
+-------------------------------------------+------+--------+------------+
#!/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,
) -> list[str]:
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), token_type.upper(), "FEHLER: kein Secret", "-"]
try:
digits = int(info.get("digits", 6))
algorithm = get_algorithm(info)
if token_type == "totp":
period = int(info.get("period", 30))
counter = now // period
remaining = period - (now % period)
code = generate_otp(secret, counter, digits, algorithm)
validity = f"{remaining}s"
elif token_type == "hotp":
counter = (
forced_counter
if forced_counter is not None
else int(info.get("counter", 0))
)
code = generate_otp(secret, counter, digits, algorithm)
validity = f"counter={counter}"
else:
return [
get_entry_label(entry),
token_type.upper(),
"ÜBERSPRUNGEN",
"nicht unterstützt",
]
return [get_entry_label(entry), token_type.upper(), code, validity]
except (ValueError, TypeError, base64.binascii.Error) as exc:
return [
get_entry_label(entry),
token_type.upper(),
f"FEHLER: {exc}",
"-",
]
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[2]
if not code.isdigit():
raise ValueError(f"OTP konnte nicht erzeugt werden: {code}")
print(code)
return
rows = [generate_row(entry, now, args.counter) for entry in entries]
rows.sort(key=lambda row: row[0].casefold())
print(table(rows, ["Account", "Typ", "OTP", "Gültigkeit"]))
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)