#!/usr/bin/env python3
"""
sigbak_decrypt.py - dechiffre une archive Sigillarium (.sigbak), format version 1.

Ecrit a partir de la specification publiee seule (https://sigillarium.app/backup_format.html),
sans reprendre le code de l'application : c'est ce qui en fait une verification
independante du format, et la garantie que vos sauvegardes restent lisibles sans
Sigillarium.

Usage :
    pip install cryptography
    python sigbak_decrypt.py sauvegarde.sigbak DOSSIER_DE_SORTIE
    (la cle de recuperation est demandee au clavier, pour ne pas finir dans l'historique)
    Pour un usage automatise, la variable d'environnement SIGBAK_KEY la fournit ;
    elle est alors visible des autres processus de la session : a eviter sinon.

Code de sortie : 0 si l'archive est intacte, 1 si des documents sont perdus ou si
l'archive est incomplete, 2 en cas d'erreur (mauvaise cle, fichier invalide).
"""

import getpass
import hashlib
import hmac
import json
import os
import re
import struct
import sys

try:
    from cryptography.exceptions import InvalidTag
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except ImportError:
    sys.exit("Module manquant : pip install cryptography")

ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
HEADER_LEN = 56
TYPE_INFO, TYPE_META, TYPE_DATA, TYPE_END = 0x01, 0x02, 0x03, 0x7F


class ArchiveError(Exception):
    pass


def json_object(plain):
    """Objet JSON d'un enregistrement authentifie, ou None s'il est illisible
    (compte comme document abime, comme dans l'application)."""
    if plain is None:
        return None
    try:
        value = json.loads(plain.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        return None
    return value if isinstance(value, dict) else None


# -- 1. Cle de recuperation ---------------------------------------------------

def parse_recovery_key(text):
    s = re.sub(r"[\s\-\u2010-\u2015]", "", text.upper())
    if len(s) != 28:
        raise ArchiveError("cle : 28 symboles attendus")
    values = []
    for ch in s:
        if ch == "O":
            values.append(0)
        elif ch in "IL":
            values.append(1)
        elif ch in ALPHABET:
            values.append(ALPHABET.index(ch))
        else:
            raise ArchiveError("cle : caractere invalide %r" % ch)
    data, check = values[:26], (values[26] << 5) | values[27]
    if check != sum((i + 1) * v for i, v in enumerate(data)) % 1021 or data[25] & 3:
        raise ArchiveError("cle : faute de frappe (somme de controle)")
    bits = 0
    for v in data:
        bits = (bits << 5) | v
    return (bits >> 2).to_bytes(16, "big")


# -- 2. Derivation (RFC 5869) -------------------------------------------------

def hkdf_sha256(ikm, salt, info, length):
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    out, block, counter = b"", b"", 1
    while len(out) < length:
        block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
        out += block
        counter += 1
    return out[:length]


# -- 3 et 4. En-tete et enregistrements ---------------------------------------

def safe_name(name, index, used):
    base = os.path.basename(str(name).replace("\\", "/")).strip() or "document"
    base = re.sub(r'[<>:"|?*\x00-\x1f]', "_", base)
    if base in (".", ".."):
        base = "document"
    candidate, n = base, 1
    while candidate in used:
        root, ext = os.path.splitext(base)
        candidate = "%s (%d)%s" % (root, n, ext)
        n += 1
    used.add(candidate)
    return candidate


def decrypt(path, key, out_dir):
    size = os.path.getsize(path)
    with open(path, "rb") as f:
        header = f.read(HEADER_LEN)
        if len(header) < HEADER_LEN or header[:6] != b"SIGBAK":
            raise ArchiveError("ce fichier n'est pas une archive Sigillarium")
        if header[6] != 1 or header[7] != 1:
            raise ArchiveError("version %d / derivation %d non prises en charge"
                               % (header[6], header[7]))

        okm = hkdf_sha256(key, header[8:40], b"Sigillarium archive v1", 64)
        enc_key, check_key = okm[:32], okm[32:]
        expected = hmac.new(check_key, header[:40], hashlib.sha256).digest()[:16]
        if not hmac.compare_digest(expected, header[40:56]):
            raise ArchiveError("mauvaise cle (ou en-tete endommage)")
        aead = AESGCM(enc_key)

        index = 0

        def read_record():
            nonlocal index
            remaining = size - f.tell()
            if remaining == 0:
                return None
            if remaining < 5 + 12 + 16:
                raise EOFError
            rtype, length = struct.unpack(">BI", f.read(5))
            if length < 16 or length > remaining - 17:
                raise EOFError
            nonce = f.read(12)
            sealed = f.read(length)
            aad = header + struct.pack(">IBI", index, rtype, length)
            index += 1
            try:
                return rtype, aead.decrypt(nonce, sealed, aad)
            except InvalidTag:
                return rtype, None

        try:
            info = read_record()
        except EOFError:
            info = None
        info_json = json_object(info[1]) if info and info[0] == TYPE_INFO else None
        if info_json is None or not isinstance(info_json.get("createdAt"), str):
            raise ArchiveError("debut d'archive illisible")
        print("Archive creee le", info_json["createdAt"])

        os.makedirs(out_dir, exist_ok=True)
        used, delivered, damaged, declared = set(), 0, 0, None
        meta_pending, meta = False, None
        try:
            while True:
                rec = read_record()
                if rec is None:
                    break
                rtype, plain = rec
                if rtype == TYPE_END:
                    entries = (json_object(plain) or {}).get("entries")
                    if isinstance(entries, int) and not isinstance(entries, bool):
                        declared = entries
                    break
                if rtype == TYPE_META:
                    if meta_pending:
                        damaged += 1
                    meta_pending = True
                    meta = json_object(plain)
                elif rtype == TYPE_DATA and meta_pending:
                    if meta is not None and plain is not None:
                        name = safe_name(meta.get("name"), delivered, used)
                        with open(os.path.join(out_dir, name), "wb") as out:
                            out.write(plain)
                        sha = meta.get("sha256")
                        if sha and hashlib.sha256(plain).hexdigest() != sha:
                            print("  ! empreinte differente :", name)
                        print("  ok", name, "(%d octets)" % len(plain))
                        delivered += 1
                    else:
                        damaged += 1
                    meta_pending, meta = False, None
        except EOFError:
            pass
        if meta_pending:
            damaged += 1

        complete = declared is not None and f.tell() == size and declared >= delivered
        if complete:
            damaged = declared - delivered
        print("%d document(s) extrait(s), %d perdu(s), archive %s"
              % (delivered, damaged, "complete" if complete else "INCOMPLETE"))
        return complete and damaged == 0


def main(argv):
    if len(argv) != 3:
        print(__doc__)
        return 2
    try:
        key = parse_recovery_key(os.environ.get("SIGBAK_KEY") or
                                 getpass.getpass("Cle de recuperation : "))
        return 0 if decrypt(argv[1], key, argv[2]) else 1
    except ArchiveError as e:
        print("Erreur :", e, file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main(sys.argv))
