#!/usr/bin/env python3
"""
sg_key_extractor.py — locate SourceGuardian's embedded commercial Blowfish
keys inside a loader binary you already legally hold (an ixed.X.Y.<platform>
file from your own SourceGuardian evaluation or licensed install).

This does NOT ship any key material. It implements the static-string search
described in "SourceGuardian PHP 8.1 — Static Container & Bytecode Recovery
Without Execution" (https://www.amariei.org/sourceguardian-php81-static-decode.html),
section 06: the loader's read-only data holds a NULL-terminated array of three
44-character base64-alphabet ASCII strings, used as-is as Blowfish keys for
every "commercial" (non machine-locked) encoded file.

Usage:
    python sg_key_extractor.py ixed.8.1.win
    python sg_key_extractor.py ixed.8.1.win --json > sg_keys.json

The printed candidates are NOT validated against a real encoded file here —
confirm them by feeding sg_keys.py into sg_container_decode.py against a
file you encoded yourself and checking the ROL1 checksum gate accepts one.
"""
import argparse
import json
import re
import sys

# 44 chars is what's been observed on the PHP 8.1 Windows loader; widen
# slightly in case another build/platform pads differently.
_CANDIDATE_RE = re.compile(rb'[A-Za-z0-9+/]{40,48}=?=?')


def find_candidates(data: bytes, min_run: int = 3, max_gap: int = 64):
    """Find runs of >= min_run candidate strings within max_gap bytes of
    each other (the loader's key array keeps the three entries close
    together, terminated by a NULL pointer/short gap)."""
    hits = [(m.start(), m.end(), m.group().decode('ascii')) for m in _CANDIDATE_RE.finditer(data)]
    groups = []
    i = 0
    while i < len(hits):
        group = [hits[i]]
        j = i + 1
        while j < len(hits) and hits[j][0] - group[-1][1] <= max_gap:
            group.append(hits[j])
            j += 1
        if len(group) >= min_run:
            groups.append(group)
        i = j
    return groups


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument('loader', help='path to your own ixed.X.Y.<platform> loader binary')
    ap.add_argument('--json', action='store_true', help='emit JSON instead of a human report')
    ap.add_argument('--max-gap', type=int, default=64, help='max byte gap between candidates in a group (default 64)')
    args = ap.parse_args()

    data = open(args.loader, 'rb').read()
    groups = find_candidates(data, min_run=3, max_gap=args.max_gap)

    if not groups:
        print('No candidate key group found. Try a larger --max-gap, or this '
              'build may lay the array out differently than the PHP 8.1 '
              'Windows reference — see the article, section 10, for the '
              'per-build porting checklist.', file=sys.stderr)
        sys.exit(1)

    if args.json:
        out = [
            {'file_offset': hex(off), 'length': end - off, 'value': val}
            for group in groups for (off, end, val) in group
        ]
        print(json.dumps(out, indent=2))
        return

    for gi, group in enumerate(groups):
        print(f'--- candidate group {gi} ({len(group)} strings) ---')
        for off, end, val in group:
            print(f'  0x{off:08x}  len={end-off:<3d}  {val}')
    print()
    print('If exactly one group has 3 entries, those are KEY0 / KEY1 / KEY2.')
    print('CONST16 for eval-mode key derivation (see article section 06) = KEY1[0:16].')


if __name__ == '__main__':
    main()
