#!/usr/bin/env python3
"""
Decryptor for the Akira-branded x64 encryptor
SHA-256 101bd45ab673fd6547386dff4339f1649e61b9f5c318260d365f766fbdc2d3c9

Pure Python 3.8+, standard library only. Optional: with numpy + numba installed, `recover` runs its
stage-1 KCipher-2 search on the compiled kernel in akira_fast.py (~600x faster; --engine to choose).

The sample encrypts each file in place and appends a 512-byte RSA-4096 footer
that seals the per-file keys. This tool decrypts a file when the keys are
available from ANY of these sources:

  --keys        key1:iv1:key2:iv2 as hex (32/16/16/16 bytes)
  --seeds       the four Yarrow seeds (decimal QueryPerformanceCounter*100
                strings, in generation order key1,iv1,key2,iv2)
  --private-key the operator's RSA private key (PEM or DER, PKCS#1 or PKCS#8);
                the footer is decrypted and everything else is read from it

How the sample encrypts (all confirmed against the live sample):
  * key1/iv1 -> ChaCha, 8 rounds, 16-byte key branch ("expand 16-byte k",
    key = key1[0:16] twice), nonce = iv1[0:8], counter from 0; a partial
    64-byte block is discarded at the end of every chunk.
  * key2/iv2 -> KCipher-2 (RFC 7008), byte-continuous keystream.
  * Encrypted regions are processed in 0xFFFF-byte chunks; in each region the
    first and the last chunk use KCipher-2, the chunks between use ChaCha8.
    Both cipher states continue across chunks and regions of the same file.
  * Mode 0 Full  : region [0, S)
    Mode 1 Spot  : region [0, p*S/100)
    Mode 2 Part  : p>=50 -> 4 stripes of p*S/100/5 bytes every (S-4B)/5
                   p<50  -> 2 stripes of p*S/100/3 bytes every (S-2B)/3
    Full = extension in the 153-entry database list; Part = extension in the
    20-entry VM/disk list or S > 2,000,000; otherwise Spot. p defaults to 50.
  * Footer: 512 bytes at offset S = RSA(PKCS#1 v1.5) of a 91-byte blob,
    each 32-bit word byte-swapped.
  * <name>.arika journal (present only if encryption was interrupted):
    [0]=state, [1:9]=progress end offset, [9:20]=plaintext header
    (1, mode, percent, size), [20:532]=the RSA blob.
"""

import argparse
import hashlib
import os
import struct
import sys

M32 = 0xFFFFFFFF
CHUNK = 0xFFFF
FOOTER = 512
PART_THRESHOLD = 2_000_000

# --------------------------------------------------------------------------
# Extension lists recovered from the binary (case-sensitive exact match)
# --------------------------------------------------------------------------
DB_EXTS = set("""
.4dd .4dl .accdb .accdc .accde .accdr .accdt .accft .adb .ade .adf .adp .arc .ora .alf .ask
.btr .bdf .cat .cdb .ckp .cma .cpd .dacpac .dad .dadiagrams .daschema .db .db-shm .db-wal .db3
.dbc .dbf .dbs .dbt .dbv .dbx .dcb .dct .dcx .ddl .dlis .dp1 .dqy .dsk .dsn .dtsx .dxl .eco .ecx
.edb .epim .exb .fcd .fdb .fic .fmp .fmp12 .fmpsl .fol .fp3 .fp4 .fp5 .fp7 .fpt .frm .gdb .grdb
.gwi .hdb .his .ib .idb .ihx .itdb .itw .jet .jtx .kdb .kexi .kexic .kexis .lgc .lwx .maf .maq
.mar .mas .mav .mdb .mdf .mpd .mrg .mud .mwb .myd .ndf .nnt .nrmlib .ns2 .ns3 .ns4 .nsf .nv .nv2
.nwdb .nyf .odb .oqy .orx .owc .p96 .p97 .pan .pdb .pdm .pnz .qry .qvd .rbf .rctd .rod .rodx .rpd
.rsd .sas7bdat .sbf .scx .sdb .sdc .sdf .sis .spq .sql .sqlite .sqlite3 .sqlitedb .te .temx .tmd
.tps .trc .trm .udb .udl .usr .v12 .vis .vpd .vvv .wdb .wmdb .wrk
""".split())
VM_EXTS = set(""".vdi .vhd .vmdk .pvm .vmem .vmsn .vmsd .nvram .vmx .raw .qcow2 .subvol .bin
.vsv .avhd .vmrs .vhdx .avdx .vmcx .iso""".split())
assert len(DB_EXTS) == 153 and len(VM_EXTS) == 20

# Embedded operator public key (DER RSAPublicKey) fingerprint, for key checks
OPERATOR_PUB_SHA256 = "a40a55f2600b9199181bc6c0778e64b2e75145c4e6f715da6d23a72407539aaa"
OPERATOR_MODULUS_PREFIX = bytes.fromhex("c2f8f4dbbe7da09f7c991d69e00b4466")

# --------------------------------------------------------------------------
# AES-256 (encrypt only) - needed by Yarrow-256
# --------------------------------------------------------------------------
def _build_sbox():
    sbox = [0] * 256
    p = q = 1
    while True:
        p = p ^ ((p << 1) & 0xFF) ^ (0x1B if p & 0x80 else 0)
        q ^= q << 1; q ^= q << 2; q ^= q << 4; q &= 0xFF
        if q & 0x80:
            q ^= 0x09
        x = q ^ (((q << 1) | (q >> 7)) & 0xFF) ^ (((q << 2) | (q >> 6)) & 0xFF) \
              ^ (((q << 3) | (q >> 5)) & 0xFF) ^ (((q << 4) | (q >> 4)) & 0xFF)
        sbox[p] = x ^ 0x63
        if p == 1:
            break
    sbox[0] = 0x63
    return sbox

SBOX = _build_sbox()

def _xt(a):
    return ((a << 1) ^ 0x1B) & 0xFF if a & 0x80 else a << 1

def _aes256_expand(key):
    w = [list(key[4 * i:4 * i + 4]) for i in range(8)]
    rcon = 1
    for i in range(8, 60):
        t = list(w[i - 1])
        if i % 8 == 0:
            t = t[1:] + t[:1]
            t = [SBOX[b] for b in t]
            t[0] ^= rcon
            rcon = _xt(rcon)
        elif i % 8 == 4:
            t = [SBOX[b] for b in t]
        w.append([a ^ b for a, b in zip(w[i - 8], t)])
    return [sum(w[4 * r:4 * r + 4], []) for r in range(15)]

def _aes256_encrypt_block(rk, block):
    s = [b ^ k for b, k in zip(block, rk[0])]
    for r in range(1, 15):
        s = [SBOX[b] for b in s]
        s = [s[(i + 4 * (i % 4)) % 16] for i in range(16)]
        if r != 14:
            n = []
            for c in range(4):
                a = s[4 * c:4 * c + 4]
                t = a[0] ^ a[1] ^ a[2] ^ a[3]
                n += [a[i] ^ t ^ _xt(a[i] ^ a[(i + 1) % 4]) for i in range(4)]
            s = n
        s = [b ^ k for b, k in zip(s, rk[r])]
    return bytes(s)

# --------------------------------------------------------------------------
# Nettle Yarrow-256 exactly as the sample uses it (one instance per value)
# --------------------------------------------------------------------------
class Yarrow256:
    def __init__(self):
        self._fast = hashlib.sha256()
        self._rk = None
        self._ctr = bytearray(16)

    def _block(self):
        out = _aes256_encrypt_block(self._rk, self._ctr)
        for i in range(15, -1, -1):
            self._ctr[i] = (self._ctr[i] + 1) & 0xFF
            if self._ctr[i]:
                break
        return out

    def seed(self, data):
        self._fast.update(data)
        digest = self._fast.digest()
        self._fast = hashlib.sha256()
        v0 = digest
        for i in range(1, 1500):
            digest = hashlib.sha256(digest + v0 + struct.pack(">I", i)).digest()
        self._rk = _aes256_expand(digest)
        self._ctr = bytearray(_aes256_encrypt_block(self._rk, bytes(16)))

    def random(self, n):
        out = b""
        while len(out) < n:
            out += self._block()
        self._rk = _aes256_expand(self._block() + self._block())
        return out[:n]

def keys_from_seeds(seeds):
    """seeds: four decimal strings in generation order key1, iv1, key2, iv2."""
    sizes = (32, 16, 16, 16)
    out = []
    for s, n in zip(seeds, sizes):
        y = Yarrow256()
        y.seed(s.strip().encode("ascii"))
        out.append(y.random(n))
    return tuple(out)

# --------------------------------------------------------------------------
# KCipher-2 (RFC 7008); tables rebuilt from basis entries read from the binary
# --------------------------------------------------------------------------
def _lin_table(basis):
    t = []
    for a in range(256):
        v = 0
        for bit in range(8):
            if a >> bit & 1:
                v ^= basis[bit]
        t.append(v)
    return t

AMUL0 = _lin_table([0xB6086D1A, 0xAF10DA34, 0x9D207768, 0xF940EED0, 0x31801F63, 0x62C33EC6, 0xC4457C4F, 0x4B8AF89E])
AMUL1 = _lin_table([0xA0F5FC2E, 0x6DC7D55C, 0xDAA387B8, 0x996B235D, 0x1FD646BA, 0x3E818C59, 0x7C2F35B2, 0xF85E6A49])
AMUL2 = _lin_table([0x5BF87F93, 0xB6BDFE6B, 0x2137B1D6, 0x426E2FE1, 0x84DC5E8F, 0x45F5BC53, 0x8AA735A6, 0x59036A01])
AMUL3 = _lin_table([0x4559568B, 0x8AB2AC73, 0x71013DE6, 0xE2027AA9, 0xA104F437, 0x27088D6E, 0x4E107FDC, 0x9C20FEDD])

def _subk2_tables():
    t0, t1, t2, t3 = [], [], [], []
    for a in range(256):
        s = SBOX[a]
        s2 = _xt(s)
        s3 = s2 ^ s
        t0.append((s3 << 24) | (s << 16) | (s << 8) | s2)   # byte 0 of the input word
        t1.append((s << 24) | (s << 16) | (s2 << 8) | s3)   # byte 1
        t2.append((s << 24) | (s2 << 16) | (s3 << 8) | s)   # byte 2
        t3.append((s2 << 24) | (s3 << 16) | (s << 8) | s)   # byte 3
    return t0, t1, t2, t3

_T0, _T1, _T2, _T3 = _subk2_tables()

def _sub_k2(x):
    return _T0[x & 0xFF] ^ _T1[(x >> 8) & 0xFF] ^ _T2[(x >> 16) & 0xFF] ^ _T3[x >> 24]

class KCipher2:
    def __init__(self, key, iv):
        if len(key) != 16 or len(iv) != 16:
            raise ValueError("KCipher-2 needs a 16-byte key and IV")
        k = list(struct.unpack(">4I", key))
        v = list(struct.unpack(">4I", iv))
        ik = k + [0] * 8
        ik[4] = ik[0] ^ _sub_k2(((ik[3] << 8) | (ik[3] >> 24)) & M32) ^ 0x01000000
        ik[5] = ik[1] ^ ik[4]; ik[6] = ik[2] ^ ik[5]; ik[7] = ik[3] ^ ik[6]
        ik[8] = ik[4] ^ _sub_k2(((ik[7] << 8) | (ik[7] >> 24)) & M32) ^ 0x02000000
        ik[9] = ik[5] ^ ik[8]; ik[10] = ik[6] ^ ik[9]; ik[11] = ik[7] ^ ik[10]
        self.a = [ik[4], ik[3], ik[2], ik[1], ik[0]]
        self.b = [ik[10], ik[11], v[0], v[1], ik[8], ik[9], v[2], v[3], ik[7], ik[5], ik[6]]
        self.l1 = self.r1 = self.l2 = self.r2 = 0
        for _ in range(24):
            self._next(True)
        self._buf = b""

    def _next(self, init):
        a, b = self.a, self.b
        nl1 = _sub_k2((self.r2 + b[4]) & M32)
        nr1 = _sub_k2((self.l2 + b[9]) & M32)
        nl2 = _sub_k2(self.l1)
        nr2 = _sub_k2(self.r1)
        na4 = ((a[0] << 8) & M32) ^ AMUL0[a[0] >> 24] ^ a[3]
        if init:
            na4 ^= ((b[0] + self.r2) & M32) ^ self.r1 ^ a[4]
        if (a[2] >> 30) & 1:
            t1 = ((b[0] << 8) & M32) ^ AMUL1[b[0] >> 24]
        else:
            t1 = ((b[0] << 8) & M32) ^ AMUL2[b[0] >> 24]
        if (a[2] >> 31) & 1:
            t2 = ((b[8] << 8) & M32) ^ AMUL3[b[8] >> 24]
        else:
            t2 = b[8]
        nb10 = t1 ^ b[1] ^ b[6] ^ t2
        if init:
            nb10 ^= ((b[10] + self.l2) & M32) ^ self.l1 ^ a[0]
        a.pop(0); a.append(na4)
        b.pop(0); b.append(nb10)
        self.l1, self.r1, self.l2, self.r2 = nl1, nr1, nl2, nr2

    def keystream(self, n):
        parts = [self._buf]
        have = len(self._buf)
        while have < n:
            a, b = self.a, self.b
            zh = ((b[10] + self.l2) & M32) ^ self.l1 ^ a[0]
            zl = ((b[0] + self.r2) & M32) ^ self.r1 ^ a[4]
            parts.append(struct.pack(">2I", zh, zl))
            have += 8
            self._next(False)
        ks = b"".join(parts)
        self._buf = ks[n:]
        return ks[:n]

    def xor(self, data):
        ks = self.keystream(len(data))
        return (int.from_bytes(data, "little") ^ int.from_bytes(ks, "little")).to_bytes(len(data), "little")

# --------------------------------------------------------------------------
# ChaCha as configured by the sample (16-byte branch, 8 rounds)
# --------------------------------------------------------------------------
class ChaChaSample:
    def __init__(self, key1, iv1, rounds=8):
        k = key1[:16]
        self.s = list(struct.unpack("<4I", b"expand 16-byte k")) + \
            list(struct.unpack("<4I", k)) + list(struct.unpack("<4I", k)) + \
            [0, 0] + list(struct.unpack("<2I", iv1[:8]))
        self.rounds = rounds

    def _block(self):
        x = self.s[:]
        for _ in range(self.rounds // 2):
            for a, b, c, d in ((0, 4, 8, 12), (1, 5, 9, 13), (2, 6, 10, 14), (3, 7, 11, 15),
                               (0, 5, 10, 15), (1, 6, 11, 12), (2, 7, 8, 13), (3, 4, 9, 14)):
                x[a] = (x[a] + x[b]) & M32; t = x[d] ^ x[a]; x[d] = ((t << 16) | (t >> 16)) & M32
                x[c] = (x[c] + x[d]) & M32; t = x[b] ^ x[c]; x[b] = ((t << 12) | (t >> 20)) & M32
                x[a] = (x[a] + x[b]) & M32; t = x[d] ^ x[a]; x[d] = ((t << 8) | (t >> 24)) & M32
                x[c] = (x[c] + x[d]) & M32; t = x[b] ^ x[c]; x[b] = ((t << 7) | (t >> 25)) & M32
        out = struct.pack("<16I", *[(x[i] + self.s[i]) & M32 for i in range(16)])
        self.s[12] = (self.s[12] + 1) & M32
        if self.s[12] == 0:
            self.s[13] = (self.s[13] + 1) & M32
        return out

    def xor(self, data):
        # Each call starts on a fresh block; the tail of a partial block is discarded.
        n = len(data)
        ks = b"".join(self._block() for _ in range((n + 63) // 64))[:n]
        return (int.from_bytes(data, "little") ^ int.from_bytes(ks, "little")).to_bytes(n, "little")

# --------------------------------------------------------------------------
# Key blob, RSA footer and private-key parsing
# --------------------------------------------------------------------------
def _rev_words(b):
    return b"".join(b[i:i + 4][::-1] for i in range(0, len(b), 4))

def parse_blob(blob):
    """91-byte RSA plaintext -> dict(mode, percent, size, key1, iv1, key2, iv2)."""
    if len(blob) != 91 or blob[0] != 1:
        raise ValueError("not a valid 91-byte key blob")
    w1 = _rev_words(blob[11:59])
    w2 = _rev_words(blob[59:91])
    k1, v1 = [], []
    for i in range(12):
        (v1 if i % 3 == 2 else k1).append(w1[4 * i:4 * i + 4])
    k2, v2 = [], []
    for i in range(8):
        (v2 if i % 2 else k2).append(w2[4 * i:4 * i + 4])
    return {"mode": blob[1], "percent": blob[2], "size": struct.unpack("<Q", blob[3:11])[0],
            "key1": b"".join(k1), "iv1": b"".join(v1), "key2": b"".join(k2), "iv2": b"".join(v2)}

def build_blob(mode, percent, size, key1, iv1, key2, iv2):
    """Inverse of parse_blob (used by the self-test)."""
    kd = [key1[i:i + 4] for i in range(0, 32, 4)]; vd = [iv1[i:i + 4] for i in range(0, 16, 4)]
    a = [vd.pop(0) if i % 3 == 2 else kd.pop(0) for i in range(12)]
    k2 = [key2[i:i + 4] for i in range(0, 16, 4)]; v2 = [iv2[i:i + 4] for i in range(0, 16, 4)]
    b = [v2.pop(0) if i % 2 else k2.pop(0) for i in range(8)]
    return bytes([1, mode, percent]) + struct.pack("<Q", size) + _rev_words(b"".join(a)) + _rev_words(b"".join(b))

def _der_read(buf, pos):
    tag = buf[pos]; pos += 1
    ln = buf[pos]; pos += 1
    if ln & 0x80:
        nb = ln & 0x7F
        ln = int.from_bytes(buf[pos:pos + nb], "big"); pos += nb
    return tag, buf[pos:pos + ln], pos + ln

def _der_ints(seq):
    out, pos = [], 0
    while pos < len(seq):
        tag, val, pos = _der_read(seq, pos)
        out.append((tag, val))
    return out

def load_private_key(path):
    raw = open(path, "rb").read()
    if b"-----BEGIN" in raw:
        import base64
        lines = [l for l in raw.decode("ascii", "replace").splitlines() if l and not l.startswith("-----")]
        raw = base64.b64decode("".join(lines))
    tag, body, _ = _der_read(raw, 0)
    items = _der_ints(body)
    if len(items) >= 3 and items[1][0] == 0x30 and items[2][0] == 0x04:      # PKCS#8 wrapper
        tag, body, _ = _der_read(items[2][1], 0)
        items = _der_ints(body)
    ints = [int.from_bytes(v, "big") for t, v in items if t == 0x02]
    if len(ints) < 4:
        raise ValueError("unrecognised RSA private key format")
    n, e, d = ints[1], ints[2], ints[3]
    return n, e, d

def decrypt_footer(footer, n, d):
    if len(footer) != FOOTER:
        raise ValueError("footer must be 512 bytes")
    c = int.from_bytes(_rev_words(footer), "big")
    if c >= n:
        raise ValueError("footer does not fit this modulus (wrong key?)")
    em = pow(c, d, n).to_bytes((n.bit_length() + 7) // 8, "big")
    if em[0] != 0 or em[1] != 2:
        raise ValueError("PKCS#1 v1.5 padding check failed (wrong key?)")
    sep = em.index(0, 2)
    if sep < 10:
        raise ValueError("PKCS#1 v1.5 padding too short")
    return em[sep + 1:]

# --------------------------------------------------------------------------
# Mode and region logic (mirrors FUN_1400ae840 / b23a0 / b3580 / b4780)
# --------------------------------------------------------------------------
MODE_NAMES = {0: "full", 1: "spot", 2: "part"}

def infer_mode(original_name, size, percent):
    ext = os.path.splitext(original_name)[1]
    if ext in DB_EXTS:
        return 0, 0
    if ext in VM_EXTS or size > PART_THRESHOLD:
        return 2, percent
    return 1, percent

def regions(mode, percent, size):
    if mode == 0:
        return [(0, size)]
    if mode == 1:
        return [(0, (percent * size) // 100)]
    if mode == 2:
        p = percent & 0xFF
        div, blocks = (3, 2) if p < 50 else (5, 4)
        blk = ((p * size) // 100) // div
        stride = (size - blocks * blk) // div
        return [(i * stride, blk) for i in range(blocks)]
    raise ValueError("unknown mode %r" % mode)

def chunk_plan(mode, percent, size):
    """Yield (offset, length, cipher) in the exact order the sample processed them."""
    for start, length in regions(mode, percent, size):
        off = 0
        while off < length:
            n = min(CHUNK, length - off, size - (start + off))
            if n <= 0:
                break
            cipher = "k2" if off == 0 or length <= off + n else "chacha"
            yield start + off, n, cipher
            off += n

# --------------------------------------------------------------------------
# File decryption
# --------------------------------------------------------------------------
def decrypt_stream(fin, fout, size, mode, percent, keys, progress_end=None, log=print):
    key1, iv1, key2, iv2 = keys
    k2 = KCipher2(key2, iv2)
    cc = ChaChaSample(key1, iv1)
    # copy the whole original-size body first, then patch the encrypted chunks
    fin.seek(0)
    remaining = size
    while remaining:
        buf = fin.read(min(1 << 20, remaining))
        if not buf:
            raise IOError("input shorter than the original size")
        fout.write(buf)
        remaining -= len(buf)
    done = 0
    for off, n, cipher in chunk_plan(mode, percent, size):
        if progress_end is not None and off + n > progress_end:
            log("  stopping at offset %d (journal progress end %d)" % (off, progress_end))
            break
        fin.seek(off)
        data = fin.read(n)
        plain = (k2 if cipher == "k2" else cc).xor(data)
        fout.seek(off)
        fout.write(plain)
        done += n
    fout.truncate(size)
    return done

def read_journal(path):
    j = open(path, "rb").read()
    if len(j) < 532:
        raise ValueError("journal shorter than 532 bytes")
    state = j[0]
    progress = struct.unpack("<Q", j[1:9])[0]
    hdr = j[9:20]
    if hdr[0] != 1:
        raise ValueError("journal header does not start with 0x01")
    return {"state": state, "progress": progress, "mode": hdr[1], "percent": hdr[2],
            "size": struct.unpack("<Q", hdr[3:11])[0], "blob": j[20:532]}

def resolve_keys(args, footer_or_blob, priv):
    if args.keys:
        parts = [bytes.fromhex(p) for p in args.keys.split(":")]
        if [len(p) for p in parts] != [32, 16, 16, 16]:
            raise ValueError("--keys must be key1(32B):iv1(16B):key2(16B):iv2(16B) in hex")
        return tuple(parts), None
    if args.seeds:
        seeds = [s for s in args.seeds.replace(";", ",").split(",") if s.strip()]
        if len(seeds) != 4:
            raise ValueError("--seeds needs four values: key1,iv1,key2,iv2")
        return keys_from_seeds(seeds), None
    if priv:
        n, d = priv
        info = parse_blob(decrypt_footer(footer_or_blob, n, d))
        return (info["key1"], info["iv1"], info["key2"], info["iv2"]), info
    raise ValueError("no key source: give --keys, --seeds or --private-key")

def decrypt_file(path, args, priv=None, log=print):
    st = os.path.getsize(path)
    journal = read_journal(args.journal) if args.journal else None
    is_akira = path.endswith(".akira")
    if journal is not None:
        size, rsa_blob = journal["size"], journal["blob"]
        if st < size:
            raise ValueError("file is smaller than the journal's original size")
        original_name = path[:-6] if is_akira else path
    else:
        if st < FOOTER:
            raise ValueError("file too small to carry a footer")
        size = st - FOOTER
        with open(path, "rb") as f:
            f.seek(size)
            rsa_blob = f.read(FOOTER)
        original_name = path[:-6] if is_akira else path

    keys, info = resolve_keys(args, rsa_blob, priv)

    if info is not None:
        mode, percent = info["mode"], info["percent"]
        if info["size"] != size:
            raise ValueError("footer size %d does not match file layout %d" % (info["size"], size))
        source = "RSA footer"
    elif journal is not None:
        mode, percent, source = journal["mode"], journal["percent"], "journal header"
    else:
        mode, percent = infer_mode(original_name, size, args.percent)
        source = "extension/size rules, percent=%d" % args.percent
    if args.mode:
        mode = {"full": 0, "spot": 1, "part": 2}[args.mode]
        percent = 0 if mode == 0 else args.percent
        source = "forced by --mode"

    out = args.out or original_name
    if os.path.exists(out) and not args.force:
        raise FileExistsError("%s exists (use --force or --out)" % out)
    progress = None
    if journal is not None:
        progress = journal["progress"] if journal["state"] == 1 else None
        if journal["state"] == 0:
            log("  journal state 0: the last chunk may or may not have been written; "
                "decrypting up to the previous chunk")
            progress = journal["progress"] - 1
        if journal["state"] == 0xFF:
            log("  journal state 0xFF: nothing was encrypted yet")
            progress = 0
    log("%s\n  original size %d, mode %s (%s), regions %s" %
        (path, size, MODE_NAMES[mode], source, regions(mode, percent, size)))
    with open(path, "rb") as fin, open(out, "w+b") as fout:
        n = decrypt_stream(fin, fout, size, mode, percent, keys, progress, log)
    log("  decrypted %d bytes -> %s" % (n, out))
    return out

# --------------------------------------------------------------------------
# Self-test: vectors captured from the live sample + RFC 7008
# --------------------------------------------------------------------------
def selftest(verbose=True):
    ok = True
    def check(name, cond):
        nonlocal ok
        ok &= bool(cond)
        if verbose:
            print("  [%s] %s" % ("PASS" if cond else "FAIL", name))

    # RFC 7008 Appendix C.1
    check("KCipher-2 RFC 7008 C.1 (zero key/IV)",
          KCipher2(bytes(16), bytes(16)).keystream(16).hex() == "f871ebef945b7272e40c04941dff0537")

    # Yarrow seeds -> RSA blob key material (captured, run 1, thread 0x444)
    k = keys_from_seeds(["867151895800", "882441766800", "894629870400", "936082862900"])
    blob = build_blob(1, 50, 0x2000, *k)
    check("Yarrow-256 seeds reproduce captured blob", blob[11:].hex() ==
          "80574ff7aed46119f4c799a71177586e83d073611bddb3b839a0cabca6906986b5b25deca53716769fbf7daa"
          "27a63afaccab41279e5a937a457cabf78b40516522597da921f8d6673057b2609436140c")

    # Captured 91-byte blob of file A (run 2) -> keys seen at the cipher inits
    blob_a = bytes.fromhex(
        "010132a5200600000000007a610ac207ed20ce0542a9671890d825428778434f4d73133e334391b4058d"
        "b6d6233d4f522d94e1a365176e2042f984807fad9e490fdb8f114ae008cf6879fe5437a64d33fed2994e1b570cc0b71c14")
    info = parse_blob(blob_a)
    key1 = bytes.fromhex("c20a617ace20ed0725d89018437887429143333eb68d05b4e1942d526e1765a3")
    iv1 = bytes.fromhex("67a9420513734d4f4f3d23d684f94220")
    key2 = bytes.fromhex("9ead7f8008e04a114da637540c571b4e")
    iv2 = bytes.fromhex("8fdb0f49fe7968cf99d2fe33141cb7c0")
    check("blob parser recovers file A keys", (info["key1"], info["iv1"], info["key2"], info["iv2"]) ==
          (key1, iv1, key2, iv2) and info["mode"] == 1 and info["percent"] == 50 and info["size"] == 0x620A5)

    # File A chunk 1 (KCipher-2) and chunk 2 (ChaCha8) captured plaintext/ciphertext
    pt1 = bytes.fromhex("504B03043300030063008B3C4B5DCCAE624AA51F060000B40F0044000B0031303162643435616236373366643635343733383664666634333339663136343965")
    ct1 = bytes.fromhex("51C2E3D6E0BE80214A67E9365CCF70EDE48E17A048A3F1A10A6797B417269F492B56E4CB9775785154F33AD1036086C8AE49DABC30DAB098CBCB5C68F8E36029")
    check("KCipher-2 decrypts captured chunk 1", KCipher2(key2, iv2).xor(ct1) == pt1)
    pt2 = bytes.fromhex("9312E488B8D3E5F177DF51DC3F60B454EE84A86C6146A76D473AAB8457215E2B1CAB0E733286EE6D98B78660C65FC6E10DB701603683B9F65CD50F7230A789F768FE6EE75B5825B207A4D7760F0BA9A3B36411FFB181D369A8C42AD90E57F2EE568FCC194118387F7A42B385BF34B4AB665C928FAAB51B06AF7ADAAA613B0E15")
    ct2 = bytes.fromhex("4406B951F4E33B6FFF025DF3E801E9E5F2A20A49E8798424CB5C11EC771AF3B3EFB2C5D022F0DC2D855717FAB32FFF53C31F0E4B092F30080449C1257B3009504506A0E5DBE14F63B0345FB341372C98FC682B8C968C3208C0D39D065AA3C97A9C119DE9C96E5F177809449C50A390C34B9C532FD01644024C587C7A40D01F12")
    check("ChaCha8 decrypts captured chunk 2", ChaChaSample(key1, iv1).xor(ct2) == pt2)

    # Fast search primitives agree with the reference implementations
    check("fast Yarrow (y16) equals reference Yarrow-256",
          y16(867151895800) == k[0][:16] and y16(882441766800) == k[1] and y16(894629870400) == k[2])
    ks = KCipher2(key2, iv2).keystream(16)
    check("fast KCipher-2 matcher agrees with reference",
          _k2_match(_k2_ik(key2), iv2, struct.unpack(">4I", ks)) and
          not _k2_match(_k2_ik(key2), iv1, struct.unpack(">4I", ks)))

    # Part-mode layout observed live on a 3,000,000-byte file
    check("Part layout matches observed stripe offsets",
          [r[0] for r in regions(2, 50, 3_000_000)] == [0, 360000, 720000, 1080000])
    return ok

# --------------------------------------------------------------------------
# Seed recovery (brute force over the QPC-based Yarrow seeds)
#
# Every value the sample actually uses is the FIRST 16 bytes of one Yarrow draw:
#   key2 = Y(s3)[0:16]   iv2 = Y(s4)[0:16]   key1[0:16] = Y(s1)[0:16]   iv1[0:8] = Y(s2)[0:8]
# where Y(s) = Yarrow-256 seeded with str(s) and s = QPC * 100 (nanoseconds of uptime
# on a 10 MHz QPC). So one table Y16[s] serves every key and IV.
#
# Stage 1: KCipher-2 encrypts the first chunk at offset 0 -> known header bytes give
#          a test for each (s3, s4) pair; s4 - s3 is a small, nearly constant gap.
# Stage 2: (only if the file has ChaCha chunks) walk back from s3 to s2 and s1 and
#          test the first ChaCha chunk (zeros/text heuristic or known bytes). For PDFs, if that
#          finds nothing, the same window is searched again with PDF-specific tests (see
#          "Stage-2 fallback for PDFs" below; --stage2 to choose).
# --------------------------------------------------------------------------
def _aes_t_tables():
    te0, te1, te2, te3 = [], [], [], []
    for x in range(256):
        s = SBOX[x]; s2 = _xt(s); s3 = s2 ^ s
        w = (s2 << 24) | (s << 16) | (s << 8) | s3
        te0.append(w); te1.append(((w >> 8) | (w << 24)) & M32)
        te2.append(((w >> 16) | (w << 16)) & M32); te3.append(((w >> 24) | (w << 8)) & M32)
    return te0, te1, te2, te3

_TE0, _TE1, _TE2, _TE3 = _aes_t_tables()

def _aes256_words(key):
    w = list(struct.unpack(">8I", key)); rcon = 1; S = SBOX
    for i in range(8, 60):
        t = w[i - 1]
        if i % 8 == 0:
            t = ((t << 8) | (t >> 24)) & M32
            t = (S[t >> 24] << 24) | (S[(t >> 16) & 255] << 16) | (S[(t >> 8) & 255] << 8) | S[t & 255]
            t ^= rcon << 24; rcon = _xt(rcon)
        elif i % 8 == 4:
            t = (S[t >> 24] << 24) | (S[(t >> 16) & 255] << 16) | (S[(t >> 8) & 255] << 8) | S[t & 255]
        w.append(w[i - 8] ^ t)
    return w

def _aes256_enc_fast(w, s0, s1, s2, s3):
    T0, T1, T2, T3, S = _TE0, _TE1, _TE2, _TE3, SBOX
    s0 ^= w[0]; s1 ^= w[1]; s2 ^= w[2]; s3 ^= w[3]
    k = 4
    for _ in range(13):
        t0 = T0[s0 >> 24] ^ T1[(s1 >> 16) & 255] ^ T2[(s2 >> 8) & 255] ^ T3[s3 & 255] ^ w[k]
        t1 = T0[s1 >> 24] ^ T1[(s2 >> 16) & 255] ^ T2[(s3 >> 8) & 255] ^ T3[s0 & 255] ^ w[k + 1]
        t2 = T0[s2 >> 24] ^ T1[(s3 >> 16) & 255] ^ T2[(s0 >> 8) & 255] ^ T3[s1 & 255] ^ w[k + 2]
        t3 = T0[s3 >> 24] ^ T1[(s0 >> 16) & 255] ^ T2[(s1 >> 8) & 255] ^ T3[s2 & 255] ^ w[k + 3]
        s0, s1, s2, s3 = t0, t1, t2, t3; k += 4
    return (((S[s0 >> 24] << 24) | (S[(s1 >> 16) & 255] << 16) | (S[(s2 >> 8) & 255] << 8) | S[s3 & 255]) ^ w[56],
            ((S[s1 >> 24] << 24) | (S[(s2 >> 16) & 255] << 16) | (S[(s3 >> 8) & 255] << 8) | S[s0 & 255]) ^ w[57],
            ((S[s2 >> 24] << 24) | (S[(s3 >> 16) & 255] << 16) | (S[(s0 >> 8) & 255] << 8) | S[s1 & 255]) ^ w[58],
            ((S[s3 >> 24] << 24) | (S[(s0 >> 16) & 255] << 16) | (S[(s1 >> 8) & 255] << 8) | S[s2 & 255]) ^ w[59])

_BE32 = [struct.pack(">I", i) for i in range(1500)]

def y16(seed):
    """First 16 bytes of a fresh Yarrow-256 seeded with str(seed) (fast path)."""
    sha = hashlib.sha256
    d = sha(str(seed).encode("ascii")).digest()
    v0 = d
    for i in range(1, 1500):
        d = sha(d + v0 + _BE32[i]).digest()
    w = _aes256_words(d)
    c = _aes256_enc_fast(w, 0, 0, 0, 0)               # counter = AES(key, 0)
    return struct.pack(">4I", *_aes256_enc_fast(w, *c))

def _k2_ik(key):
    k = struct.unpack(">4I", key)
    ik = list(k) + [0] * 8
    ik[4] = ik[0] ^ _sub_k2(((ik[3] << 8) | (ik[3] >> 24)) & M32) ^ 0x01000000
    ik[5] = ik[1] ^ ik[4]; ik[6] = ik[2] ^ ik[5]; ik[7] = ik[3] ^ ik[6]
    ik[8] = ik[4] ^ _sub_k2(((ik[7] << 8) | (ik[7] >> 24)) & M32) ^ 0x02000000
    ik[9] = ik[5] ^ ik[8]; ik[10] = ik[6] ^ ik[9]; ik[11] = ik[7] ^ ik[10]
    return ik

_FULL_MASK = (M32,) * 16

def _k2_match(ik, iv, target, tmask=_FULL_MASK):
    """True if KCipher-2(key schedule ik, iv) starts with the keystream words in target (word i ANDed with tmask[i])."""
    v0, v1, v2, v3 = struct.unpack(">4I", iv)
    a0, a1, a2, a3, a4 = ik[4], ik[3], ik[2], ik[1], ik[0]
    b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10 = ik[10], ik[11], v0, v1, ik[8], ik[9], v2, v3, ik[7], ik[5], ik[6]
    l1 = r1 = l2 = r2 = 0
    T0, T1, T2, T3, A0, A1, A2, A3 = _T0, _T1, _T2, _T3, AMUL0, AMUL1, AMUL2, AMUL3
    for _ in range(24):
        x = (r2 + b4) & M32; nl1 = T0[x & 255] ^ T1[(x >> 8) & 255] ^ T2[(x >> 16) & 255] ^ T3[x >> 24]
        x = (l2 + b9) & M32; nr1 = T0[x & 255] ^ T1[(x >> 8) & 255] ^ T2[(x >> 16) & 255] ^ T3[x >> 24]
        nl2 = T0[l1 & 255] ^ T1[(l1 >> 8) & 255] ^ T2[(l1 >> 16) & 255] ^ T3[l1 >> 24]
        nr2 = T0[r1 & 255] ^ T1[(r1 >> 8) & 255] ^ T2[(r1 >> 16) & 255] ^ T3[r1 >> 24]
        na4 = ((a0 << 8) & M32) ^ A0[a0 >> 24] ^ a3 ^ ((b0 + r2) & M32) ^ r1 ^ a4
        t1 = ((b0 << 8) & M32) ^ (A1[b0 >> 24] if (a2 >> 30) & 1 else A2[b0 >> 24])
        t2 = (((b8 << 8) & M32) ^ A3[b8 >> 24]) if a2 >> 31 else b8
        nb10 = t1 ^ b1 ^ b6 ^ t2 ^ ((b10 + l2) & M32) ^ l1 ^ a0
        a0, a1, a2, a3, a4 = a1, a2, a3, a4, na4
        b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10 = b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, nb10
        l1, r1, l2, r2 = nl1, nr1, nl2, nr2
    n = len(target)
    i = 0
    while True:
        if ((((b10 + l2) & M32) ^ l1 ^ a0) & tmask[i]) != target[i]:
            return False
        i += 1
        if i == n:
            return True
        if ((((b0 + r2) & M32) ^ r1 ^ a4) & tmask[i]) != target[i]:
            return False
        i += 1
        if i == n:
            return True
        x = (r2 + b4) & M32; nl1 = T0[x & 255] ^ T1[(x >> 8) & 255] ^ T2[(x >> 16) & 255] ^ T3[x >> 24]
        x = (l2 + b9) & M32; nr1 = T0[x & 255] ^ T1[(x >> 8) & 255] ^ T2[(x >> 16) & 255] ^ T3[x >> 24]
        nl2 = T0[l1 & 255] ^ T1[(l1 >> 8) & 255] ^ T2[(l1 >> 16) & 255] ^ T3[l1 >> 24]
        nr2 = T0[r1 & 255] ^ T1[(r1 >> 8) & 255] ^ T2[(r1 >> 16) & 255] ^ T3[r1 >> 24]
        na4 = ((a0 << 8) & M32) ^ A0[a0 >> 24] ^ a3
        t1 = ((b0 << 8) & M32) ^ (A1[b0 >> 24] if (a2 >> 30) & 1 else A2[b0 >> 24])
        t2 = (((b8 << 8) & M32) ^ A3[b8 >> 24]) if a2 >> 31 else b8
        nb10 = t1 ^ b1 ^ b6 ^ t2
        a0, a1, a2, a3, a4 = a1, a2, a3, a4, na4
        b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10 = b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, nb10
        l1, r1, l2, r2 = nl1, nr1, nl2, nr2

def _plausible_plain(buf):
    """Heuristic for a decrypted ChaCha chunk: many zeros or mostly text."""
    if not buf:
        return False
    zeros = buf.count(0)
    printable = sum(1 for c in buf if 32 <= c < 127 or c in (9, 10, 13))
    return zeros * 64 >= len(buf) * 3 or printable * 100 >= len(buf) * 85   # >=4.7% zeros or >=85% text

# Known file headers for the KCipher-2 oracle (offset 0)
MAGIC = {
    ".pdf": b"%PDF-1.", ".zip": b"PK\x03\x04", ".docx": b"PK\x03\x04", ".xlsx": b"PK\x03\x04",
    ".pptx": b"PK\x03\x04", ".jar": b"PK\x03\x04", ".odt": b"PK\x03\x04", ".7z": b"7z\xbc\xaf\x27\x1c",
    ".rar": b"Rar!\x1a\x07", ".png": b"\x89PNG\r\n\x1a\n", ".gif": b"GIF89a", ".jpg": b"\xff\xd8\xff",
    ".jpeg": b"\xff\xd8\xff", ".doc": b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", ".xls": b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",
    ".ppt": b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", ".msg": b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",
    ".sqlite": b"SQLite format 3\x00", ".sqlite3": b"SQLite format 3\x00", ".db": b"SQLite format 3\x00",
    ".vmdk": b"KDMV\x01\x00\x00\x00", ".vhdx": b"vhdxfile", ".qcow2": b"QFI\xfb", ".vdi": b"<<< Oracle VM VirtualBox Disk Image >>>",
    ".mdf": b"\x01\x0f\x00\x00", ".pst": b"!BDN", ".gz": b"\x1f\x8b\x08",
}

# ---- seed tables: up to two seed intervals stored back to back ----------
def _make_segs(a0, a1, b0, b1, tick):
    """Union of seed intervals [a0,a1] and [b0,b1] as [(start_seed, count), ...]."""
    lo, hi = sorted([(a0, a1), (b0, b1)])
    if hi[0] <= lo[1] + tick:
        return [(lo[0], (max(lo[1], hi[1]) - lo[0]) // tick + 1)]
    return [(lo[0], (lo[1] - lo[0]) // tick + 1), (hi[0], (hi[1] - hi[0]) // tick + 1)]

def _seg_seed(segs, tick, idx):
    for start, count in segs:
        if idx < count:
            return start + idx * tick
        idx -= count
    raise IndexError(idx)

def _seg_index(segs, tick, seed):
    off = 0
    for start, count in segs:
        k, r = divmod(seed - start, tick)
        if r == 0 and 0 <= k < count:
            return off + k
        off += count
    return -1

# ---- worker plumbing (top-level so Windows 'spawn' can pickle them) ----
_W = {}

def _w_init(shm_name, segs, tick, extra):
    from multiprocessing import shared_memory
    shm = shared_memory.SharedMemory(name=shm_name)
    _W.update(shm=shm, buf=shm.buf, segs=segs, tick=tick, **extra)

def _w_derive(args):
    start, stop = args
    buf, segs, tick = _W["buf"], _W["segs"], _W["tick"]
    for i in range(start, stop):
        buf[16 * i:16 * i + 16] = y16(_seg_seed(segs, tick, i))
    return stop - start

def _w_k2(args):
    s_lo, s_hi = args                # key2 seed sub-range (inclusive)
    buf, segs, tick, target, gmin, gmax = _W["buf"], _W["segs"], _W["tick"], _W["target"], _W["gmin"], _W["gmax"]
    tmask, jlo, jhi = _W["tmask"], _W.get("jlo"), _W.get("jhi")
    hits = []
    for s in range(s_lo, s_hi + 1, tick):
        i = _seg_index(segs, tick, s)
        ik = _k2_ik(bytes(buf[16 * i:16 * i + 16]))
        g0, g1 = gmin, gmax
        if jlo is not None:                              # --anchor iv2: keep iv2 inside its own window
            g0, g1 = max(g0, -((s - jlo) // tick)), min(g1, (jhi - s) // tick)
        if g1 < g0:
            continue
        j0 = _seg_index(segs, tick, s + g0 * tick)       # the iv2 window is contiguous in one segment
        for d in range(g1 - g0 + 1):
            j = j0 + d
            if _k2_match(ik, bytes(buf[16 * j:16 * j + 16]), target, tmask):
                hits.append((s, s + (g0 + d) * tick))
    return hits

def _w_chacha(args):
    p_lo, p_hi = args                # iv1 seed sub-range (inclusive)
    buf, segs, tick, emin, emax, ct, known = (_W["buf"], _W["segs"], _W["tick"], _W["emin"], _W["emax"],
                                              _W["ct"], _W["known"])
    hits = []
    for p in range(p_lo, p_hi + 1, tick):
        pi = _seg_index(segs, tick, p)
        iv8 = bytes(buf[16 * pi:16 * pi + 8])
        for e in range(emin, emax + 1):
            q = p - e * tick
            qi = _seg_index(segs, tick, q)
            first = ChaChaSample(bytes(buf[16 * qi:16 * qi + 16]), iv8).xor(ct[:64])
            if known is not None:
                if first[:len(known)] != known[:64]:
                    continue
            elif not _plausible_plain(first):
                continue
            hits.append((q, p))
    return hits

# --------------------------------------------------------------------------
# Stage-2 fallback for PDFs (runs only when the zeros/text test finds nothing, or with --stage2 pdf)
#
# The zeros/text test looks at the first bytes of the first ChaCha chunk. In a PDF those are often
# compressed stream data, so the right key is rejected (both PDFs in the blind runs). Once stage 1 has the
# KCipher-2 keys, the KCipher-2 chunks and the unencrypted tail are known plaintext, and a PDF offers two
# better tests:
#   xref   : classic xref tables / xref streams in known bytes give the exact offset of every "N G obj"
#            header; headers inside a ChaCha chunk are exact known plaintext (one ChaCha block per
#            candidate). Used only if the headers that fall in known bytes all sit where the xref says.
#   deflate: a FlateDecode page-content stream that starts in known bytes and runs into the first ChaCha
#            chunk of a region: prime zlib with the known prefix, continue it with each candidate's
#            output and require valid content-stream syntax (numbers, names, strings, operators with the
#            right operand count). A survivor is confirmed only if the whole stream then inflates to its
#            end (zlib checks the Adler-32).
# Neither test applies to every PDF (no in-region objects, or a binary stream at the chunk start); the
# fallback then says so and the result is the same as before.
# --------------------------------------------------------------------------
def _chacha_block(key16, iv8, blk):
    cc = ChaChaSample(key16 + bytes(16), iv8)
    cc.s[12], cc.s[13] = blk & M32, (blk >> 32) & M32
    return cc._block()

def _chacha_chunks(plan):
    """[(offset, length, first keystream block)] for every ChaCha chunk, in processing order.
    The ChaCha state runs on across chunks and regions; each chunk discards its last partial block."""
    out, base = [], 0
    for off, n, c in plan:
        if c == "chacha":
            out.append((off, n, base))
            base += (n + 63) // 64
    return out

def _partial_plain(path, size, mode, pct, key2, iv2):
    """The file with KCipher-2 chunks decrypted (ChaCha chunks still wrong) and the plaintext tail."""
    import io
    out = io.BytesIO()
    with open(path, "rb") as f:
        decrypt_stream(f, out, size, mode, pct, (bytes(32), bytes(16), key2, iv2), log=lambda *_: None)
    return bytearray(out.getvalue())

def _pdf_xref_entries(body):
    """In-use (offset, objnum, gen) from every classic xref table and xref stream that parses."""
    import re, zlib
    ents = set()
    row = re.compile(rb"(\d{10}) (\d{5}) ([nf])\s{1,2}")
    sub = re.compile(rb"\s*(\d+)\s+(\d+)\s*[\r\n]+")
    for m in re.finditer(rb"(?:^|[\r\n])xref\s*[\r\n]", body):
        i = m.end()
        while True:
            h = sub.match(body, i)
            if not h:
                break
            first, count = int(h.group(1)), int(h.group(2))
            i = h.end()
            for k in range(count):
                r = row.match(body, i)
                if not r:
                    break
                if r.group(3) == b"n":
                    ents.add((int(r.group(1)), first + k, int(r.group(2))))
                i = r.end()
    for m in re.finditer(rb"<<(?:(?!>>\s*stream).){0,600}?/Type\s*/XRef(?:(?!>>\s*stream).){0,600}?>>\s*stream\r?\n",
                         body, re.S):
        dic = m.group(0)
        try:
            n = int(re.search(rb"/Length\s+(\d+)(?!\s+\d+\s+R)", dic).group(1))
            w = [int(v) for v in re.search(rb"/W\s*\[\s*([\d\s]+)\]", dic).group(1).split()]
            raw = zlib.decompress(bytes(body[m.end():m.end() + n])) if b"FlateDecode" in dic else bytes(body[m.end():m.end() + n])
            cols = re.search(rb"/Columns\s+(\d+)", dic)
            rw = sum(w)
            if re.search(rb"/Predictor\s+1[0-5]", dic):
                c = int(cols.group(1)) if cols else rw
                rows, prev = [], bytes(c)
                for k in range(0, len(raw) - c, c + 1):
                    ft, r = raw[k], bytearray(raw[k + 1:k + 1 + c])
                    if ft == 2:
                        for j in range(c):
                            r[j] = (r[j] + prev[j]) & 255
                    elif ft != 0:
                        raise ValueError("unsupported PNG predictor %d" % ft)
                    rows.append(bytes(r)); prev = bytes(r)
            else:
                rows = [raw[k:k + rw] for k in range(0, len(raw) - rw + 1, rw)]
            idx = re.search(rb"/Index\s*\[\s*([\d\s]+)\]", dic)
            size = int(re.search(rb"/Size\s+(\d+)", dic).group(1))
            v = [int(a) for a in idx.group(1).split()] if idx else [0, size]
            ids = [o for a, b in zip(v[::2], v[1::2]) for o in range(a, a + b)]
            for oid, r in zip(ids, rows):
                f = [int.from_bytes(r[sum(w[:k]):sum(w[:k + 1])], "big") for k in range(3)]
                if w[0] == 0:
                    f[0] = 1
                if f[0] == 1:
                    ents.add((f[1], oid, f[2]))
        except Exception:
            continue
    return sorted(ents)

def _pdf_xref_targets(body, plan, log):
    """Object headers inside ChaCha chunks: [(offset, block, offset in block, bytes)], or [] if unusable."""
    cc = _chacha_chunks(plan)
    ents = _pdf_xref_entries(body)
    inside, checked, good = [], 0, 0
    for off, num, gen in ents:
        hdr = b"%d %d obj" % (num, gen)
        c = next((c for c in cc if c[0] <= off < c[0] + c[1]), None)
        if c is None:
            if off + len(hdr) <= len(body) and not any(x[0] < off + len(hdr) and off < x[0] + x[1] for x in cc):
                checked += 1
                good += bytes(body[off:off + len(hdr)]) == hdr
            continue
        rel = off - c[0]
        if rel + len(hdr) <= c[1] and rel % 64 + len(hdr) <= 64:
            inside.append((off, c[2] + rel // 64, rel % 64, hdr))
    log("  stage 2 (pdf/xref): %d xref entries; %d headers in known bytes, %d where the xref says; "
        "%d inside ChaCha chunks" % (len(ents), checked, good, len(inside)))
    if not inside:
        return []
    if checked and good < checked:
        log("  stage 2 (pdf/xref): xref offsets do not match the known bytes; test not used")
        return []
    return inside

_PDF_TOK = None
_PDF_ARITY = {
    b"b": 0, b"B": 0, b"b*": 0, b"B*": 0, b"BDC": 2, b"BMC": 1, b"BT": 0, b"BX": 0, b"c": 6, b"cm": 6, b"CS": 1,
    b"cs": 1, b"d": 2, b"d0": 2, b"d1": 6, b"Do": 1, b"DP": 2, b"EMC": 0, b"ET": 0, b"EX": 0, b"f": 0, b"F": 0,
    b"f*": 0, b"G": 1, b"g": 1, b"gs": 1, b"h": 0, b"i": 1, b"j": 1, b"J": 1, b"K": 4, b"k": 4, b"l": 2, b"m": 2,
    b"M": 1, b"MP": 1, b"n": 0, b"q": 0, b"Q": 0, b"re": 4, b"RG": 3, b"rg": 3, b"ri": 1, b"s": 0, b"S": 0,
    b"SC": None, b"sc": None, b"SCN": None, b"scn": None, b"sh": 1, b"T*": 0, b"Tc": 1, b"Td": 2, b"TD": 2,
    b"Tf": 2, b"Tj": 1, b"TJ": 1, b"TL": 1, b"Tm": 6, b"Tr": 1, b"Ts": 1, b"Tw": 1, b"Tz": 1, b"v": 4, b"w": 1,
    b"W": 0, b"W*": 0, b"y": 4, b"'": 1, b'"': 3,
}

def _pdf_syntax(text):
    """(good, bad) operator groups in a run of page-content text; the first group and last token are
    dropped because they may be cut. Inline image data (ID ... EI) stops the scan."""
    import re
    global _PDF_TOK
    if _PDF_TOK is None:
        _PDF_TOK = re.compile(rb"\((?:[^()\\]|\\.)*\)|<[0-9A-Fa-f\s]*>|<<|>>|\[|\]|/[^\s/\[\]()<>{}%]*"
                              rb"|[+-]?(?:\d+\.?\d*|\.\d+)|[A-Za-z'\"][A-Za-z0-9*'\"]*\*?|%[^\r\n]*|\S", re.S)
    toks = _PDF_TOK.findall(text)[:-1]
    good = bad = ops = depth = 0
    started = False                                      # nothing counts until the first operator after the cut
    for t in toks:
        c = t[:1]
        if c == b"%":
            continue
        if t == b"[" or t == b"<<":
            if depth == 0:
                ops += 1
            depth += 1
            continue
        if t == b"]" or t == b">>":
            depth = max(0, depth - 1)
            continue
        if depth:
            continue
        if (c in b"(/0123456789" or (c == b"<" and len(t) > 1 and t.endswith(b">"))
                or (c in b"+-." and len(t) > 1)):
            ops += 1
            continue
        if t == b"ID":
            break
        want = _PDF_ARITY.get(t, -1)
        if started:
            if want == -1 or (want is not None and want != ops):
                bad += 1
            else:
                good += 1
        started = True
        ops = 0
    return good, bad

def _pdf_deflate_setup(body, plan, log):
    """A FlateDecode content stream that runs from known bytes into the first ChaCha chunk of a region:
    (stream start, stream length, chunk offset, chunk length, first block, compressed prefix), or None."""
    import re, zlib
    cc = _chacha_chunks(plan)
    prev = {}
    for i, (off, n, c) in enumerate(plan):
        prev[off] = plan[i - 1] if i else None
    for c_off, c_n, base in cc:
        p = prev.get(c_off)
        if not p or p[2] != "k2" or p[0] + p[1] != c_off:
            continue                                     # only a region's first ChaCha chunk follows known bytes
        lo = max(0, c_off - 4_000_000)
        for m in reversed(list(re.finditer(rb"(?<!end)stream\r?\n", body[lo:c_off]))):
            s0 = lo + m.end()
            dic = bytes(body[max(0, lo + m.start() - 1024):lo + m.start()])
            dic = dic[dic.rfind(b"obj") + 3:] if b"obj" in dic else dic
            ln = re.search(rb"/Length\s+(\d+)(?!\s+\d+\s+R)", dic)
            if not ln or b"/FlateDecode" not in dic or re.search(rb"/Filter\s*\[[^\]]*/\w+[^\]]*/\w+", dic):
                continue
            n = int(ln.group(1))
            if not s0 + n > c_off:
                break                                    # the nearest stream ends before the chunk
            d = zlib.decompressobj()
            try:
                txt = d.decompress(bytes(body[s0:c_off]))
            except zlib.error:
                continue
            good, bad = _pdf_syntax(txt[-6000:])
            if good >= 20 and bad == 0:
                log("  stage 2 (pdf/deflate): content stream at %d..%d spans ChaCha chunk %d; known prefix %d bytes "
                    "inflates to %d bytes of page content" % (s0, s0 + n, c_off, c_off - s0, len(txt)))
                return s0, n, c_off, c_n, base, bytes(body[s0:c_off])
            log("  stage 2 (pdf/deflate): the stream spanning chunk %d is not page content (%d good, %d bad); "
                "test not used" % (c_off, good, bad))
            break
    return None

def _w_pdf(args):
    import zlib
    p_lo, p_hi = args
    buf, segs, tick, emin, emax, kind = _W["buf"], _W["segs"], _W["tick"], _W["emin"], _W["emax"], _W["kind"]
    hits = []
    if kind == "xref":
        off, blk, inb, hdr = _W["target"]
        want = bytes(a ^ b for a, b in zip(_W["ct"], hdr))
    else:
        if "d0" not in _W:
            d = zlib.decompressobj()
            _W["tail"] = d.decompress(_W["prefix"])[-256:]
            _W["d0"] = d
        d0, tail, ct, blk0 = _W["d0"], _W["tail"], _W["ct"], _W["blk"]
    for p in range(p_lo, p_hi + 1, tick):
        pi = _seg_index(segs, tick, p)
        iv8 = bytes(buf[16 * pi:16 * pi + 8])
        for e in range(emin, emax + 1):
            q = p - e * tick
            qi = _seg_index(segs, tick, q)
            if qi < 0:
                continue
            k16 = bytes(buf[16 * qi:16 * qi + 16])
            if kind == "xref":
                if _chacha_block(k16, iv8, blk)[inb:inb + len(hdr)] == want:
                    hits.append((q, p))
                continue
            dec = d0.copy()
            try:
                a = dec.decompress(bytes(x ^ y for x, y in zip(ct[:64], _chacha_block(k16, iv8, blk0))))
            except zlib.error:
                continue
            good, bad = _pdf_syntax(tail + a)
            if bad > 1 or (bad == 1 and good < 6) or good < 2:
                continue
            try:
                b = dec.decompress(_xor_from_block(k16, iv8, blk0 + 1, ct[64:1024]))
            except zlib.error:
                continue
            good, bad = _pdf_syntax(tail + a + b)
            if good >= 20 and bad * 33 <= good:
                hits.append((q, p))
    return hits

def _xor_from_block(k16, iv8, blk, data):
    cc = ChaChaSample(k16 + bytes(16), iv8)
    cc.s[12], cc.s[13] = blk & M32, (blk >> 32) & M32
    return cc.xor(data)

def _pdf_stage2(path, body, plan, segs2, p_lo, p_hi, emin, emax, tick, workers, log):
    """Search the same stage-2 window with the PDF tests; returns (key1 seed, iv1 seed, key1, iv1) or None."""
    import time, zlib
    from multiprocessing import Pool
    with open(path, "rb") as f:
        enc = f.read(len(body))
    tests = []
    xt = _pdf_xref_targets(body, plan, log)
    if xt:
        tests.append(("xref", xt))
    ds = _pdf_deflate_setup(body, plan, log)
    if ds:
        tests.append(("deflate", ds))
    if not tests:
        log("  stage 2 (pdf): neither PDF test applies to this file")
        return None
    cc = _chacha_chunks(plan)
    for kind, info in tests:
        t0 = time.time()
        if kind == "xref":
            off, blk, inb, hdr = info[0]
            extra = {"kind": kind, "target": info[0], "ct": enc[off:off + len(hdr)]}
            log("  stage 2 (pdf/xref): testing %r at %d (keystream block %d) for every pair ..." % (hdr, off, blk))
        else:
            s0, n, c_off, c_n, base, prefix = info
            extra = {"kind": kind, "prefix": prefix, "ct": enc[c_off:c_off + min(1024, c_n)], "blk": base}
            log("  stage 2 (pdf/deflate): continuing the stream with every pair ...")
        extra.update(emin=emin, emax=emax)
        shm = _derive_table(segs2, tick, workers, log)
        try:
            cands = []
            with Pool(workers, initializer=_w_init, initargs=(shm.name, segs2, tick, extra)) as pool:
                for hits in pool.imap_unordered(_w_pdf, _seed_jobs(p_lo, p_hi, tick, workers, cap=16)):
                    cands += hits
            keys = []
            for q, p in sorted(cands):
                qi, pi = _seg_index(segs2, tick, q), _seg_index(segs2, tick, p)
                keys.append((q, p, bytes(shm.buf[16 * qi:16 * qi + 16]), bytes(shm.buf[16 * pi:16 * pi + 16])))
        finally:
            shm.close(); shm.unlink()
        log("  stage 2 (pdf/%s): %d candidate(s) after %.1f s" % (kind, len(keys), time.time() - t0))
        for q, p, k16, iv in keys:
            if kind == "xref":
                ok = sum(_chacha_block(k16, iv[:8], b)[i:i + len(h)] == bytes(x ^ y for x, y in zip(enc[o:o + len(h)], h))
                         for o, b, i, h in info)
                log("    key1 seed %d, iv1 seed %d: %d/%d object headers" % (q, p, ok, len(info)))
                if ok * 10 >= len(info) * 9:
                    return q, p, k16 + bytes(16), iv
            else:
                trial = bytearray(body)
                ccs = ChaChaSample(k16 + bytes(16), iv)
                for o, ln, _ in cc:
                    trial[o:o + ln] = ccs.xor(enc[o:o + ln])
                s0, n = info[0], info[1]
                d = zlib.decompressobj()
                try:
                    d.decompress(bytes(trial[s0:s0 + n]))
                    whole = d.eof
                except zlib.error:
                    whole = False
                log("    key1 seed %d, iv1 seed %d: whole stream inflates with a valid Adler-32: %s" % (q, p, whole))
                if whole:
                    return q, p, k16 + bytes(16), iv
    return None

def _derive_table(segs, tick, workers, log):
    from multiprocessing import shared_memory, Pool
    count = sum(c for _, c in segs)
    shm = shared_memory.SharedMemory(create=True, size=max(16 * count, 16))
    step = max(1, min(256, count // (workers * 8) or 1))
    jobs = [(s, min(s + step, count)) for s in range(0, count, step)]
    done, mark = 0, 0
    with Pool(workers, initializer=_w_init, initargs=(shm.name, segs, tick, {})) as pool:
        for n in pool.imap_unordered(_w_derive, jobs):
            done += n
            if done * 10 // count > mark:
                mark = done * 10 // count
                log("    derived %d/%d seeds" % (done, count))
    return shm

def _seed_jobs(lo, hi, tick, workers, cap=64):
    n = (hi - lo) // tick + 1
    step = max(1, min(cap, n // (workers * 8) or 1))
    return [(lo + k * tick, min(lo + (k + step - 1) * tick, hi)) for k in range(0, n, step)]

def _parse_range(text, name):
    try:
        a, b = text.split(":")
        a, b = int(float(a)), int(float(b))
    except Exception:
        raise ValueError("%s must be MIN:MAX" % name)
    if b < a:
        raise ValueError("%s: MAX < MIN" % name)
    return a, b

def _parse_filetime(text):
    """FILETIME as an integer (100 ns since 1601) or an ISO-8601 UTC string with up to 7 decimals."""
    text = text.strip()
    if text.isdigit():
        return int(text)
    import datetime
    t = text.rstrip("Zz")
    frac = 0
    if "." in t:
        t, f = t.split(".", 1)
        frac = int((f + "0000000")[:7])
    d = datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S")
    return (d - datetime.datetime(1601, 1, 1)) // datetime.timedelta(microseconds=1) * 10 + frac

def _load_fast(engine, log):
    """The optional Numba stage-1 kernel (akira_fast.py), checked against the Python reference before use."""
    if engine == "python":
        return None
    here = os.path.dirname(os.path.abspath(__file__))
    if here not in sys.path:                 # python -I leaves the script's own folder off sys.path
        sys.path.append(here)
    sys.modules.setdefault("akira_decrypt", sys.modules[__name__])   # akira_fast imports us; reuse this copy
    try:
        import akira_fast
    except ImportError as e:
        if engine == "numba":
            raise SystemExit("--engine numba: %s (pip install numpy numba)" % e)
        log("  numba not installed: using the pure-Python matcher (pip install numpy numba for ~600x faster stage 1)")
        return None
    bad = akira_fast.selftest(trials=200)
    if bad:
        log("  numba kernel disagrees with the reference on %d of 400 checks: using the pure-Python matcher" % bad)
        return None
    return akira_fast

def _seg_index_np(np, segs, tick, seeds):
    idx = np.full(seeds.shape, -1, np.int64)
    off = 0
    for start, count in segs:
        k = (seeds - start) // tick
        ok = (k >= 0) & (k < count) & ((seeds - start) % tick == 0)
        idx = np.where(ok, off + k, idx)
        off += count
    return idx

def _k2_search_fast(fast, shm, count, segs, tick, s_from, s_to, gmin, gmax, iv2win, target, tmask, path, known, log):
    """Stage 1 on the Numba kernel: same candidate pairs as _w_k2, same full-KCipher-2 confirmation."""
    import time
    np = fast.np
    words = fast.words_from_table(shm.buf, count)
    nk = (s_to - s_from) // tick + 1
    seeds = s_from + tick * np.arange(nk, dtype=np.int64)
    g0 = np.full(nk, gmin, np.int64)
    g1 = np.full(nk, gmax, np.int64)
    if iv2win:
        g0 = np.maximum(g0, -((seeds - iv2win[0]) // tick))
        g1 = np.minimum(g1, (iv2win[1] - seeds) // tick)
    cnt = np.maximum(g1 - g0 + 1, 0)
    kidx = _seg_index_np(np, segs, tick, seeds)
    j0 = np.where(cnt > 0, _seg_index_np(np, segs, tick, seeds + g0 * tick), 0)
    assert (kidx >= 0).all() and (j0 >= 0).all()
    with open(path, "rb") as f:
        head = f.read(len(known))
    total = int(cnt.sum())
    cum = np.cumsum(cnt)
    step = 200_000_000                      # ~2 s per batch at ~100 M pairs/s
    a, done, mark, t0 = 0, 0, 0, time.time()
    while a < nk:
        b = int(np.searchsorted(cum, cum[a] - cnt[a] + step, side="right"))
        b = min(max(b, a + 1), nk)
        d0 = np.zeros(b - a, np.int64)
        while True:
            out = fast.scan(words, kidx[a:b], j0[a:b], cnt[a:b], target, tmask, d0)
            again = False
            for i in np.nonzero(out >= 0)[0]:
                d = int(out[i])
                key2 = bytes(shm.buf[16 * kidx[a + i]:16 * kidx[a + i] + 16])
                iv2 = bytes(shm.buf[16 * (j0[a + i] + d):16 * (j0[a + i] + d) + 16])
                if KCipher2(key2, iv2).xor(head) == known:
                    s = int(seeds[a + i])
                    return s, s + int(g0[a + i] + d) * tick, key2, iv2
                d0[:] = cnt[a:b]; d0[i] = d + 1  # filter false positive: rescan only that key, past it
                again = True
            if not again:
                break
        done += int(cum[b - 1] - (cum[a] - cnt[a]))
        a = b
        if done * 10 // max(total, 1) > mark:
            mark = done * 10 // max(total, 1)
            el = time.time() - t0
            log("    tested %d/%d pairs (%d%%), %.1f M pairs/s" % (done, total, 100 * done // total, done / max(el, 1e-9) / 1e6))
    return None

def recover(args, log=print):
    import time
    path = args.path
    st = os.path.getsize(path)
    size = st - FOOTER
    original_name = path[:-6] if path.endswith(".akira") else path
    mode, pct = infer_mode(original_name, size, args.percent)
    if args.mode:
        mode = {"full": 0, "spot": 1, "part": 2}[args.mode]; pct = 0 if mode == 0 else args.percent
    plan = list(chunk_plan(mode, pct, size))
    if not plan:
        raise ValueError("nothing encrypted in this file under mode %s" % MODE_NAMES[mode])
    with open(path, "rb") as f:
        ct0 = f.read(min(64, plan[0][1]))
        chacha_chunk = next((c for c in plan if c[2] == "chacha"), None)
        ct2 = b""
        if chacha_chunk:
            f.seek(chacha_chunk[0]); ct2 = f.read(min(64, chacha_chunk[1]))

    # known plaintext for KCipher-2
    if args.known_hex:
        known = bytes.fromhex(args.known_hex)
    elif args.known_file:
        known = open(args.known_file, "rb").read(32)
    else:
        known = MAGIC.get(os.path.splitext(original_name)[1].lower())
        if known is None:
            raise ValueError("no known header for this extension; give --known-hex or --known-file")
    known = known[:len(ct0)]
    if len(known) < 7:      # 7 bytes = a 2^-56 filter, e.g. the version-agnostic "%PDF-1."
        raise ValueError("need at least 7 known plaintext bytes at offset 0 (have %d)" % len(known))
    known = known[:64]
    nwords = (len(known) + 3) // 4
    ks = bytes(a ^ b for a, b in zip(ct0, known)) + b"\0" * (4 * nwords - len(known))
    tmask = [M32] * nwords
    if len(known) % 4:      # partial last word: compare only its leading known bytes
        tmask[-1] = (M32 << (8 * (4 - len(known) % 4))) & M32
    target = tuple(w & m for w, m in zip(struct.unpack(">%dI" % nwords, ks), tmask))

    tick = args.tick
    if args.uptime:
        u0, u1 = [float(x) for x in args.uptime.split(":")]
        s_from, s_to = int(u0 * 1e9) // tick * tick, int(u1 * 1e9) // tick * tick
    elif args.qpc_offset:
        # seed = uptime in ns = (FILETIME - FILETIME of QPC 0) * 100, whatever the QPC frequency.
        # Run 4: mtime - key2 was +0.45..+5.36 ms on 12 KB files with a ~1 ms clock tick.
        off = _parse_filetime(args.qpc_offset)
        if args.mtime:
            mt = _parse_filetime(args.mtime)
        else:
            mt = os.stat(path).st_mtime_ns // 100 + 116444736000000000
            log("  --mtime not given: using this copy's last-write time; valid only if the copy kept it")
        back, fwd = [float(x) for x in args.mtime_window.split(":")]
        s_from = (mt - off - int(back * 1e4)) * 100 // tick * tick
        s_to = (mt - off + int(fwd * 1e4)) * 100 // tick * tick
        log("  key2 window from mtime %d - QPC offset %d, -%g/+%g ms: %d..%d" % (mt, off, back, fwd, s_from, s_to))
        if args.floor:
            # the folder's akira_readme.txt is written just before its first file (run 4: 1.3 ms before
            # the first key1), so no key2 in that folder is older than the note's creation time - 2 ms
            fl = (_parse_filetime(args.floor) - off - 20000) * 100 // tick * tick
            if args.floor_only:
                s_from, s_to = fl, s_from - tick
            else:
                s_from = max(s_from, fl)
            log("  --floor %s: key2 window now %d..%d" % (args.floor, s_from, s_to))
        if s_to < s_from:
            raise ValueError("empty key2 window")
    else:
        s_from, s_to = _parse_range(args.key2_range, "--key2-range")
        s_from -= s_from % tick
    gmin, gmax = _parse_range(args.gap, "--gap")
    iv2win = None
    if args.anchor == "iv2":
        # the window above holds iv2, not key2: key2 = iv2 - gap. Run 5: mtime - iv2 was 0.8..1.8 ms on all
        # solved files, so this keeps a large-gap search narrow (cost = iv2 window x gap span)
        iv2win = (s_from, s_to)
        s_from, s_to = s_from - gmax * tick, s_to - gmin * tick
        log("  --anchor iv2: iv2 window %d..%d, key2 window %d..%d" % (iv2win + (s_from, s_to)))
    nk = (s_to - s_from) // tick + 1
    if iv2win:
        segs = _make_segs(s_from, s_to, iv2win[0], iv2win[1], tick)
        npairs = sum(max(0, min(gmax, (iv2win[1] - s) // tick) - max(gmin, (iv2win[0] - s + tick - 1) // tick) + 1)
                     for s in range(s_from, s_to + 1, tick))
    else:
        segs = _make_segs(s_from, s_to, s_from + gmin * tick, s_to + gmax * tick, tick)
        npairs = nk * (gmax - gmin + 1)
    count = sum(c for _, c in segs)
    workers = args.workers or os.cpu_count() or 1
    log("%s: mode %s, %d known bytes (%d-bit filter), key2 candidates %d, gap %d..%d ticks"
        % (path, MODE_NAMES[mode], len(known), 8 * len(known), nk, gmin, gmax))
    fast = _load_fast(getattr(args, "engine", "auto"), log)
    log("  stage 1: deriving %d Yarrow outputs on %d workers ..." % (count, workers))
    t0 = time.time()
    shm = _derive_table(segs, tick, workers, log)
    try:
        hit = None
        if fast:
            log("  stage 1: testing %d KCipher-2 pairs (numba, %d threads; table derived in %.1f s) ..."
                % (npairs, fast.threads(args.workers or None), time.time() - t0))
            hit = _k2_search_fast(fast, shm, count, segs, tick, s_from, s_to, gmin, gmax, iv2win, target, tmask,
                                  path, known, log)
        else:
            log("  stage 1: testing %d KCipher-2 pairs (pure Python) ..." % npairs)
            from multiprocessing import Pool
            with Pool(workers, initializer=_w_init,
                      initargs=(shm.name, segs, tick, {"target": target, "tmask": tmask, "gmin": gmin, "gmax": gmax,
                                                       "jlo": iv2win and iv2win[0], "jhi": iv2win and iv2win[1]})) as pool:
                for hits in pool.imap_unordered(_w_k2, _seed_jobs(s_from, s_to, tick, workers)):
                    for s3c, s4c in hits:
                        i, j = _seg_index(segs, tick, s3c), _seg_index(segs, tick, s4c)
                        key2, iv2 = bytes(shm.buf[16 * i:16 * i + 16]), bytes(shm.buf[16 * j:16 * j + 16])
                        with open(path, "rb") as f:
                            head = f.read(len(known))
                        if KCipher2(key2, iv2).xor(head) == known:
                            hit = (s3c, s4c, key2, iv2)
                            break
                    if hit:
                        pool.terminate()
                        break
    finally:
        shm.close(); shm.unlink()
    if not hit:
        log("  no KCipher-2 seed pair found in the window (%.1f s)" % (time.time() - t0))
        return None
    s3, s4, key2, iv2 = hit
    log("  FOUND key2 seed %d, iv2 seed %d (gap %d ticks) after %.1f s" % (s3, s4, (s4 - s3) // tick, time.time() - t0))

    key1, iv1, s1, s2 = bytes(32), bytes(16), None, None
    if chacha_chunk:
        g = (s4 - s3) // tick
        if args.gap_iv1:
            dmin, dmax = _parse_range(args.gap_iv1, "--gap-iv1")
        else:
            # found gap +/- jitter, widened to the --gap window: natively the three gaps
            # share one distribution (run 4: floor ~3,890 ticks, median ~4,100)
            dmin = min(int(g * (1 - args.jitter)), gmin)
            dmax = max(int(g * (1 + args.jitter)) + 1, gmax)
        if args.gap_key1:
            emin, emax = _parse_range(args.gap_key1, "--gap-key1")
        else:
            emin, emax = dmin, dmax
        known2 = bytes.fromhex(args.known2_hex) if args.known2_hex else None
        p_lo, p_hi = s3 - dmax * tick, s3 - dmin * tick          # iv1 seed window
        q_lo, q_hi = p_lo - emax * tick, p_hi - emin * tick      # key1 seed window
        segs2 = _make_segs(q_lo, q_hi, p_lo, p_hi, tick)
        log("  stage 2: ChaCha seeds, iv1 gap %d..%d, key1 gap %d..%d (%d derivations, %d pairs)"
            % (dmin, dmax, emin, emax, sum(c for _, c in segs2), (dmax - dmin + 1) * (emax - emin + 1)))
        stage2 = getattr(args, "stage2", "auto") if known2 is None else "heuristic"
        is_pdf = os.path.splitext(original_name)[1].lower() == ".pdf"
        found = None
        if stage2 != "pdf":
            shm = _derive_table(segs2, tick, workers, log)
            try:
                from multiprocessing import Pool
                with Pool(workers, initializer=_w_init,
                          initargs=(shm.name, segs2, tick,
                                    {"emin": emin, "emax": emax, "ct": ct2, "known": known2})) as pool:
                    for hits in pool.imap_unordered(_w_chacha, _seed_jobs(p_lo, p_hi, tick, workers, cap=16)):
                        for q, p in hits:
                            qi, pi = _seg_index(segs2, tick, q), _seg_index(segs2, tick, p)
                            k1 = bytes(shm.buf[16 * qi:16 * qi + 16]) + bytes(16)
                            v1 = bytes(shm.buf[16 * pi:16 * pi + 16])
                            with open(path, "rb") as f:
                                f.seek(chacha_chunk[0]); blk = f.read(min(1024, chacha_chunk[1]))
                            if known2 is not None or _plausible_plain(ChaChaSample(k1, v1).xor(blk)):
                                found = (q, p, k1, v1)
                                break
                        if found:
                            pool.terminate()
                            break
            finally:
                shm.close(); shm.unlink()
            if not found and stage2 == "auto" and is_pdf:
                log("  stage 2: zeros/text test found nothing; trying the PDF tests on the same window")
        if not found and (stage2 == "pdf" or (stage2 == "auto" and is_pdf)):
            body = _partial_plain(path, size, mode, pct, key2, iv2)
            found = _pdf_stage2(path, body, plan, segs2, p_lo, p_hi, emin, emax, tick, workers, log)
        if not found:
            log("  KCipher-2 keys found but ChaCha seeds not found in the window; only the first/last chunks will decrypt")
        else:
            s1, s2, key1, iv1 = found
            log("  FOUND key1 seed %d, iv1 seed %d" % (s1, s2))
    else:
        log("  file has no ChaCha chunks: key1/iv1 not needed")
    seeds = ",".join(str(s) if s is not None else "?" for s in (s1, s2, s3, s4))
    keys = ":".join(k.hex() for k in (key1, iv1, key2, iv2))
    log("  seeds  : %s" % seeds)
    log("  --keys : %s" % keys)
    if args.out:
        a = argparse.Namespace(keys=keys, seeds=None, journal=None, out=args.out, force=args.force,
                               percent=args.percent, mode=args.mode)
        decrypt_file(path, a, None, log)
    return s1, s2, s3, s4, key1, iv1, key2, iv2

def benchmark(seconds_window, gap_span, workers, log=print):
    import time
    t = time.time(); n = 0
    while time.time() - t < 2.0:
        y16(4000000000000 + n * 100); n += 1
    d_rate = n / (time.time() - t)
    ik = _k2_ik(bytes(16)); tgt = (1, 2); t = time.time(); m = 0
    while time.time() - t < 2.0:
        _k2_match(ik, struct.pack(">4I", m, 0, 0, 0), tgt); m += 1
    p_rate = m / (time.time() - t)
    nk = int(seconds_window * 1e7)
    pairs = nk * gap_span
    est = (nk + gap_span) / (d_rate * workers) + pairs / (p_rate * workers)
    log("per core: %.0f Yarrow derivations/s, %.0f KCipher-2 pair tests/s" % (d_rate, p_rate))
    fast = _load_fast("auto", log)
    if fast:
        np = fast.np
        w = np.random.default_rng(0).integers(0, 2 ** 32, size=(30000, 4), dtype=np.int64)
        k = np.arange(2000, dtype=np.int64); c = np.full(2000, 20000, np.int64)
        fast.scan(w, k[:8], k[:8] + 5000, c[:8] // 100, (1, 2), (M32, M32))      # compile / warm up
        t = time.time(); fast.scan(w, k, k + 5000, c, (1, 2), (M32, M32)); f_rate = 4e7 / (time.time() - t)
        est = (nk + gap_span) / (d_rate * workers) + pairs / f_rate
        log("numba kernel: %.1f M KCipher-2 pair tests/s on %d threads" % (f_rate / 1e6, fast.threads()))
    log("window %.3f s of uptime (%d seeds) x gap span %d ticks = %.3g pairs" % (seconds_window, nk, gap_span, pairs))
    log("estimated stage-1 time on %d workers: %.1f s (%.2f h)" % (workers, est, est / 3600))

# --------------------------------------------------------------------------
def main(argv=None):
    ap = argparse.ArgumentParser(description="Decrypt .akira files from sample 101bd45a...c3c9")
    sub = ap.add_subparsers(dest="cmd", required=True)

    d = sub.add_parser("decrypt", help="decrypt one file, or many with --private-key")
    d.add_argument("paths", nargs="+", help=".akira file(s) or folders (folders need --private-key)")
    d.add_argument("--keys", help="key1:iv1:key2:iv2 in hex")
    d.add_argument("--seeds", help="four Yarrow seeds key1,iv1,key2,iv2 (decimal strings)")
    d.add_argument("--private-key", help="operator RSA private key (PEM/DER, PKCS#1 or PKCS#8)")
    d.add_argument("--percent", type=int, default=50, help="--encryption_percent used by the attacker (default 50)")
    d.add_argument("--mode", choices=["full", "spot", "part"], help="override the inferred mode")
    d.add_argument("--journal", help="<name>.arika journal for an interrupted file")
    d.add_argument("--out", help="output path (single file only)")
    d.add_argument("--force", action="store_true", help="overwrite the output if it exists")

    i = sub.add_parser("info", help="show layout and planned chunks without decrypting")
    i.add_argument("path")
    i.add_argument("--percent", type=int, default=50)
    i.add_argument("--private-key")

    r = sub.add_parser("recover", help="brute-force the per-file Yarrow seeds (no RSA key needed)")
    r.add_argument("path", help=".akira file")
    w = r.add_mutually_exclusive_group(required=True)
    w.add_argument("--uptime", help="window of machine uptime at encryption time, seconds MIN:MAX (e.g. 4277.4:4277.7)")
    w.add_argument("--key2-range", help="window of key2 seed values MIN:MAX (nanoseconds, as the sample prints them)")
    w.add_argument("--qpc-offset", help="FILETIME (int or ISO-8601 UTC) of QPC = 0 on the boot that ran the encryptor; "
                                        "measure on the live victim as precise system time minus QPC. The key2 window "
                                        "is then derived from the file's last-write time (--mtime, --mtime-window)")
    r.add_argument("--mtime", help="the .akira file's last-write time as FILETIME (int) or ISO-8601 UTC "
                                   "(default: this copy's own mtime)")
    r.add_argument("--floor", help="with --qpc-offset: creation time (FILETIME or ISO) of the folder's akira_readme.txt; "
                                   "key2 is never older than this minus 2 ms, so the window is clipped to it")
    r.add_argument("--floor-only", action="store_true",
                   help="with --floor: search only from the floor up to the start of the --mtime-window "
                        "(the slice a first -8/+1 ms pass did not cover)")
    r.add_argument("--mtime-window", default="8:1",
                   help="key2 window around mtime minus offset, BACK:FWD in ms (default 8:1; run 4 measured "
                        "+0.45..+5.36 ms on small files with a ~1 ms clock tick; add ~16 ms of BACK on a host at the "
                        "default 15.6 ms tick, and the file's encryption time for large files)")
    r.add_argument("--anchor", choices=["key2", "iv2"], default="key2",
                   help="which seed the --mtime-window / --uptime / --key2-range window bounds (default key2); "
                        "iv2 suits large-gap searches, since iv2 sits 0.8-1.8 ms before mtime on solved files")
    r.add_argument("--gap", default="3800:8000",
                   help="ticks between the key2 and iv2 seeds, MIN:MAX (default 3800:8000: measured native gap on "
                        "the analysis VM was 3,889..27,109, median 4,121; 20 of 21 under 8,000. Scales with CPU speed; "
                        "widen if nothing is found)")
    r.add_argument("--gap-iv1", help="ticks between the iv1 and key2 seeds, MIN:MAX (default: found gap +/- jitter, "
                                     "widened to the --gap window)")
    r.add_argument("--gap-key1", help="ticks between the key1 and iv1 seeds, MIN:MAX (default: same as --gap-iv1)")
    r.add_argument("--jitter", type=float, default=0.25, help="relative jitter for default ChaCha gap windows")
    r.add_argument("--known-hex", help="known plaintext at offset 0 (hex); default from extension")
    r.add_argument("--known-file", help="a clean file of the same type; its first 32 bytes are used")
    r.add_argument("--known2-hex", help="known plaintext at the first ChaCha chunk (default: zeros/text heuristic)")
    r.add_argument("--stage2", choices=["auto", "heuristic", "pdf"], default="auto",
                   help="ChaCha seed test: heuristic = zeros/text only; pdf = only the PDF tests (object headers "
                        "from the xref, or continuing a page-content stream); auto = heuristic, then the PDF tests "
                        "for .pdf files when the heuristic finds nothing (default)")
    r.add_argument("--tick", type=int, default=100, help="seed units per QPC tick (100 on 10 MHz QPC)")
    r.add_argument("--percent", type=int, default=50)
    r.add_argument("--mode", choices=["full", "spot", "part"])
    r.add_argument("--workers", type=int, default=0, help="processes (default: all CPUs)")
    r.add_argument("--engine", choices=["auto", "numba", "python"], default="auto",
                   help="stage-1 KCipher-2 search: numba kernel (akira_fast.py, needs numpy + numba) or pure Python; "
                        "auto = numba when installed (default)")
    r.add_argument("--out", help="decrypt to this path once keys are found")
    r.add_argument("--force", action="store_true")

    b = sub.add_parser("benchmark", help="measure search speed and estimate a window's cost")
    b.add_argument("--window", type=float, default=0.01, help="uptime window in seconds (default 0.01)")
    b.add_argument("--gap-span", type=int, default=4200, help="number of candidate gaps (ticks; default 4200 = 3800:8000)")
    b.add_argument("--workers", type=int, default=0)

    sub.add_parser("selftest", help="run built-in validation vectors")
    args = ap.parse_args(argv)

    if args.cmd == "selftest":
        print("Self-test:")
        sys.exit(0 if selftest() else 1)
    if args.cmd == "recover":
        sys.exit(0 if recover(args) else 1)
    if args.cmd == "benchmark":
        benchmark(args.window, args.gap_span, args.workers or os.cpu_count() or 1)
        return

    priv = None
    if getattr(args, "private_key", None):
        n, e, dd = load_private_key(args.private_key)
        if n.to_bytes(512, "big")[:16] != OPERATOR_MODULUS_PREFIX:
            print("warning: private key modulus does not match the key embedded in this sample", file=sys.stderr)
        priv = (n, dd)

    if args.cmd == "info":
        st = os.path.getsize(args.path)
        size = st - FOOTER
        name = args.path[:-6] if args.path.endswith(".akira") else args.path
        if priv:
            with open(args.path, "rb") as f:
                f.seek(size)
                inf = parse_blob(decrypt_footer(f.read(FOOTER), *priv))
            mode, pct = inf["mode"], inf["percent"]
            print("footer: mode=%s percent=%d size=%d" % (MODE_NAMES[mode], pct, inf["size"]))
        else:
            mode, pct = infer_mode(name, size, args.percent)
            print("inferred (no key): mode=%s percent=%d" % (MODE_NAMES[mode], pct))
        print("original size %d; regions %s" % (size, regions(mode, pct, size)))
        plan = list(chunk_plan(mode, pct, size))
        print("%d chunks, %d bytes encrypted (%.1f%%)" % (len(plan), sum(c[1] for c in plan),
              100.0 * sum(c[1] for c in plan) / max(size, 1)))
        return

    targets = []
    for p in args.paths:
        if os.path.isdir(p):
            for root, _, files in os.walk(p):
                targets += [os.path.join(root, f) for f in files if f.endswith(".akira")]
        else:
            targets.append(p)
    if len(targets) > 1 and (args.keys or args.seeds or args.journal or args.out):
        ap.error("--keys/--seeds/--journal/--out are per-file; give a single file")
    failures = 0
    for t in targets:
        try:
            decrypt_file(t, args, priv)
        except Exception as ex:  # keep going over a folder
            failures += 1
            print("ERROR %s: %s" % (t, ex), file=sys.stderr)
    sys.exit(1 if failures else 0)

if __name__ == "__main__":
    main()
