"""
Auto2FA — os seus códigos 2FA em Python.  pip install cryptography

    from auto2fa import codigo
    c = codigo(link=os.environ["AUTO2FA_LINK"], senha=os.environ.get("AUTO2FA_SENHA"), nome="AWS")
    print(c["codigo"])

TUDO ACONTECE NA SUA MÁQUINA: baixa o cofre CIFRADO, abre com a sua senha aqui
e calcula o código aqui. A senha mestra e o segredo do 2FA nunca saem deste
processo. Mesmo comportamento do auto2fa.mjs (Node) — os dois são conferidos
contra o código da extensão pela suíte de testes.

`link`: o link da API do cofre pessoal (https://auth.doublesix.com.br/#api=<id>.<chave>)
+ `senha`; ou um convite a2fa-eq3… de um acesso de máquina (sem senha).
"""
import base64
import hashlib
import hmac
import json
import struct
import time
import urllib.error
import urllib.parse
import urllib.request

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.keywrap import InvalidUnwrap, aes_key_unwrap

VERSAO = "1.1.0"
URL_PADRAO = "https://auth.doublesix.com.br"


class Auto2FAErro(Exception):
    pass


def _b64(t):
    return base64.b64decode(t)


def _completar(t):
    n = t.replace("-", "+").replace("_", "/")
    return n + "=" * ((4 - len(n) % 4) % 4)


def _conferir_url(texto):
    u = urllib.parse.urlsplit(texto)
    local = u.hostname in ("127.0.0.1", "localhost", "::1")
    if u.scheme != "https" and not (u.scheme == "http" and local):
        raise Auto2FAErro("O endereço precisa ser https.")
    return f"{u.scheme}://{u.netloc}"


def _soma(partes):
    h = 0x811C9DC5
    for ch in ".".join(partes):
        h ^= ord(ch)
        h = (h * 0x01000193) & 0xFFFFFFFF
    return f"{h:08x}"


def ler_link(link):
    texto = (link or "").strip()
    if not texto:
        raise Auto2FAErro("Faltou o link (AUTO2FA_LINK).")
    if texto.startswith("a2fa-eq3."):
        p = texto.split(".")
        if len(p) != 7 or p[6] != _soma(p[:6]):
            raise Auto2FAErro("O convite chegou incompleto ou alterado.")
        _, id_, chave, acesso, chave_url, url_b64 = p[:6]
        url = base64.b64decode(_completar(url_b64)).decode()
        return {"tipo": "equipe", "url": _conferir_url(url), "id": id_, "chave": chave,
                "acessoId": acesso, "chavePessoa": _completar(chave_url)}
    if texto.startswith("a2fa-eq"):
        raise Auto2FAErro("Convite de formato antigo. Peça um convite a2fa-eq3.")
    url, cred = URL_PADRAO, texto
    if texto.startswith(("http://", "https://")):
        u = urllib.parse.urlsplit(texto)
        url = _conferir_url(texto)
        cred = urllib.parse.parse_qs(u.fragment).get("api", [""])[0]
    id_, _, chave = cred.partition(".")
    if len(id_) != 16 or not chave:
        raise Auto2FAErro('O link não traz a credencial do cofre. Copie o "link da API" no site.')
    return {"tipo": "pessoal", "url": url, "id": id_, "chave": chave}


def _baixar(alvo):
    req = urllib.request.Request(f"{alvo['url']}/api/cofre", headers={
        "authorization": f"Bearer {alvo['id']}.{alvo['chave']}", "user-agent": f"auto2fa-py/{VERSAO}"})
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return json.loads(json.loads(r.read())["dados"])
    except urllib.error.HTTPError as e:
        if e.code == 401:
            raise Auto2FAErro("O cofre não existe mais, ou a credencial do link não confere.") from None
        raise Auto2FAErro(f"O servidor respondeu {e.code}.") from None


def _abrir_pessoal(pacote, senha):
    if not senha:
        raise Auto2FAErro("Faltou a senha mestra (AUTO2FA_SENHA).")
    chave = hashlib.pbkdf2_hmac("sha256", senha.encode(), _b64(pacote["salt"]), pacote["iteracoes"], 32)
    try:
        # Formato 2: a senha abre a chave de dados, e ela abre as contas.
        if int(pacote.get("versao", 1)) >= 2:
            chave = AESGCM(chave).decrypt(_b64(pacote["senha"]["iv"]), _b64(pacote["senha"]["dados"]), None)
        claro = AESGCM(chave).decrypt(_b64(pacote["iv"]), _b64(pacote["dados"]), None)
    except Exception:
        raise Auto2FAErro("Senha mestra incorreta.") from None
    return json.loads(claro)


def _abrir_equipe(bloco, alvo):
    if bloco.get("formato") != "a2fa-cofre3":
        raise Auto2FAErro("Este cofre não está no formato de acesso por pessoa.")
    meu = bloco.get("acessos", {}).get(alvo["acessoId"])
    if not meu:
        raise Auto2FAErro("Este acesso não existe mais neste cofre.")
    kek = _b64(alvo["chavePessoa"])
    contas = []
    for conta_id, embrulhada in meu["chaves"].items():
        cifrada = bloco["contas"].get(conta_id)
        if not cifrada:
            continue
        try:
            chave = aes_key_unwrap(kek, _b64(embrulhada))
        except InvalidUnwrap:
            raise Auto2FAErro("A chave deste convite não confere com o cofre.") from None
        conta = json.loads(AESGCM(chave).decrypt(_b64(cifrada["iv"]), _b64(cifrada["dados"]), None))
        conta["id"] = conta_id
        contas.append(conta)
    return contas


def abrir(link, senha=None):
    alvo = ler_link(link)
    bloco = _baixar(alvo)
    return _abrir_pessoal(bloco, senha) if alvo["tipo"] == "pessoal" else _abrir_equipe(bloco, alvo)


def gerar_codigo(conta, agora=None):
    periodo = conta.get("periodo") or 30
    digitos = conta.get("digitos") or 6
    algo = {"SHA-1": hashlib.sha1, "SHA-256": hashlib.sha256, "SHA-512": hashlib.sha512}[conta.get("algoritmo") or "SHA-1"]
    segredo = conta["segredo"].upper().replace(" ", "").replace("-", "").rstrip("=")
    chave = base64.b32decode(segredo + "=" * ((8 - len(segredo) % 8) % 8))
    contador = int((time.time() if agora is None else agora) // periodo)
    s = hmac.new(chave, struct.pack(">Q", contador), algo).digest()
    o = s[-1] & 0x0F
    n = struct.unpack(">I", s[o:o + 4])[0] & 0x7FFFFFFF
    return str(n % 10 ** digitos).zfill(digitos)


def achar_conta(contas, nome, usuario=None):
    alvo = (nome or "").strip().lower()
    rot = lambda c: f"{c['nome']} ({c['usuario']})" if c.get("usuario") else c["nome"]
    achadas = [c for c in contas if c["nome"].strip().lower() == alvo or rot(c).lower() == alvo]
    if usuario:
        achadas = [c for c in achadas if (c.get("usuario") or "").lower() == usuario.lower()]
    if len(achadas) == 1:
        return achadas[0]
    if achadas:
        raise Auto2FAErro(f'Há {len(achadas)} contas "{nome}": {", ".join(map(rot, achadas))}. Diga o usuário.')
    raise Auto2FAErro(f'Nenhuma conta "{nome}" neste cofre.')


def codigo(link, nome, senha=None, usuario=None, agora=None):
    conta = achar_conta(abrir(link, senha), nome, usuario)
    t = time.time() if agora is None else agora
    periodo = conta.get("periodo") or 30
    return {"nome": conta["nome"], "usuario": conta.get("usuario"), "codigo": gerar_codigo(conta, t),
            "expira_em_segundos": periodo - int(t) % periodo}


def listar(link, senha=None):
    return [{"nome": c["nome"], "usuario": c.get("usuario")} for c in abrir(link, senha)]


if __name__ == "__main__":
    import os
    import sys
    if len(sys.argv) < 2:
        sys.exit("uso: AUTO2FA_LINK=... AUTO2FA_SENHA=... python auto2fa.py <nome da conta>")
    try:
        print(codigo(os.environ.get("AUTO2FA_LINK"), " ".join(sys.argv[1:]), os.environ.get("AUTO2FA_SENHA"))["codigo"])
    except Auto2FAErro as e:
        sys.exit(f"auto2fa: {e}")
