#!/usr/bin/env python3
# 안심사인 증거 묶음 독립 검증 스크립트 (형식: ansimsign-evidence-v1 · v2)
#   v2 = 봉투별 키로 서명한 감사 기록(audit/chain-v2.jsonl). 분쟁 때 따로 받은 봉투 키 파일(keys/envelope.key 또는 --key)이 있으면
#        genesis 부터 앵커 끝까지 전 기록 해시를 다시 계산합니다([참고] → [통과]). 개인정보 칸은 커밋먼트이며, 따로 받은 선택 공개 파일
#        (disclosures/*.json)로 한 칸씩 원문과 대조할 수 있습니다.
#
# 안심사인 서버·데이터베이스 없이 공개 도구만으로 검증합니다.
#   필요한 것: 파이썬 3.8+ (표준 라이브러리만) · OpenSSL 3.0+ (서명·시각 도장 판정은 OpenSSL 이 합니다)
#   권장 실행: 공식 위치에서 받은 이 파일을 **묶음 폴더 밖**에 두고, 묶음과 무관한 빈 폴더에서
#             python3 -I verify_bundle.py <묶음을 푼 폴더>        (Windows: py -3 -I verify_bundle.py <묶음을 푼 폴더>)
#             -I = 파이썬 격리 모드(필수). 새로 공개된 봉인 인증서 = --trust-seal-sha256 <지문> · OpenSSL 지정 = --openssl <절대 경로>
#   ★Windows 명령 프롬프트는 현재 폴더의 실행 파일을 먼저 실행합니다 — 묶음 폴더 안에서는 어떤 명령도 치지 마세요.
#
# ★이 파일은 묶음을 위조한 사람도 바꿀 수 있습니다. 판정을 믿으려면 이 파일의 SHA-256 을 직접 계산해(sha256sum ·
#   certutil -hashfile) 공식 위치(OFFICIAL_URL)의 목록과 대조하세요. 이 파일이 스스로 찍는 해시는 근거가 아닙니다.
#
# 판정 = 전부 통과(종료 코드 0) / 실패(1) / 부분 검증(3 · 실패는 없지만 확인하지 못한 항목이 있음) · 사용법 오류(2) ·
#   검증을 끝내지 못함(4 · 예상 못 한 실행 오류 — 변조 판정이 아니다).
#   묶음 안 manifest.json 은 서명되지 않은 목록이라 검사를 끄는 데 쓰지 않습니다 — 파일이 있으면 검사하고, 없으면 "확인 못 함"입니다
#   (목록이 v2 형식이라고 적으면 audit/chain-v2.jsonl 이 있어야 한다는 검사가 하나 더 켜질 뿐입니다).
import sys

# ★다른 import 보다 먼저(2026-10-13 게이트 P1): 파이썬은 실행한 스크립트의 폴더를 모듈 검색 맨 앞에 둔다. 묶음 폴더 안에서
#   실행하면 묶음에 심어 둔 argparse.py 같은 파일이 표준 모듈 대신 읽혀 판정을 가로챈다 → 격리 모드(-I)가 아니면 그 자리를 뺀다.
if not (sys.flags.isolated or getattr(sys.flags, 'safe_path', False)) and sys.path:
    del sys.path[0]
sys.path[:] = [p for p in sys.path if p not in ('', '.')]

import argparse  # noqa: E402 — 위 정리 뒤에 읽어야 한다
import base64  # noqa: E402
import calendar  # noqa: E402
import datetime  # noqa: E402
import hashlib  # noqa: E402
import hmac  # noqa: E402
import json  # noqa: E402
import os  # noqa: E402
import re  # noqa: E402
import subprocess  # noqa: E402
import tempfile  # noqa: E402
import unicodedata  # noqa: E402

OFFICIAL_URL = 'https://ansimsign.com/security'

# >>> 신뢰 기준
# KISA RootCA 4 — 국가 전자서명 최상위 인증서. 출처: 한국인터넷진흥원 전자서명인증관리센터 https://rootca.kisa.or.kr
KISA_ROOTCA4_SHA256 = 'a002ff556c601863b08b9aa33a8e6666e97e72bbe552f66eb9f2395c68c7bc98'
KISA_ROOTCA4_PEM = '''-----BEGIN CERTIFICATE-----
MIIDdTCCAl2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJLUjEN
MAsGA1UECgwES0lTQTEuMCwGA1UECwwlS29yZWEgQ2VydGlmaWNhdGlvbiBBdXRo
b3JpdHkgQ2VudHJhbDEWMBQGA1UEAwwNS0lTQSBSb290Q0EgNDAeFw0xMDA3MTIw
OTI2MjFaFw0zMDA3MTIwOTI2MjFaMGQxCzAJBgNVBAYTAktSMQ0wCwYDVQQKDARL
SVNBMS4wLAYDVQQLDCVLb3JlYSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBDZW50
cmFsMRYwFAYDVQQDDA1LSVNBIFJvb3RDQSA0MIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAxepOYmiYpxFdu6SCSVgMCrAgswLbRSYg/+0tah/CjIHMn2Ja
506fpsyfhyN/pX4nfEFXhQsVtTaGNAkB+/cfrPOaupueh4pbwZc7KYo4maUtK7tD
j3F6iuxaa7jwAWW2/FKY0jQfBkVV9V+jfPVDJokEFGNn63zYiLcIQBaoPcQ/mJ8P
KR+gGBhgUFxIWKQvi+5croilzcu+Igm/nv93uqdBUCroBo3ttTo+IfUHuj13nOO8
VbABGRA53JPV4V3iDQBG3WPgSVjak/AQf7MP8F7HyGr42/oFI8Lb+O9W4MWGMamy
YkidxGBdrcb61qcjQQnDNWrbFsOrmKwJYv28MwIDAQABozIwMDAdBgNVHQ4EFgQU
yNCOx0muHyBCskt/E8l3WAyhzcEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B
AQsFAAOCAQEAm/GuDMpPb886Fk8nTUHVaG8gWq8JwN2sONS84IrQOn0KqM1zYGXu
Xhv8H2HKeBahLKRBQKfXqfo9WSdtdTLw3Awo1EGVQDnMYpaNMHI3fulEJ8ZCWwPC
zu4BsG583iYnG+54Oatjr5AUU0vZGMNBsMVkZf0aFg9PCJ0PSKg/iFQb48prJ0Iy
5k7KXllIisQNuZlyreATdtNU0ajYW9WHXFYC/warjyd7bSz9KmUuMjQ3xA5LW3dk
C1QLa+RHgg5lNlMNMr+sq3+cFY8LncxMby9jqL7Wx37fjdqbQ5kwjHcnSHFe9AHu
6GSd/425e2ApHFXDYnh6+m0bGkNNrg8/GQ==
-----END CERTIFICATE-----
'''
# 안심사인 봉인 인증서(공개) SHA-256 — 봉인 PDF 전자서명의 서명자는 이 중 하나여야 합니다. 새 인증서는 공식 위치에 먼저 공개됩니다.
SEAL_CERT_SHA256 = {
    'ac722c6c447ef5ccfd186a75e5a3992f3bf7debbb27770095c90f51aede5ceec': 'AnsimSign Document Seal · WATONE Holdings Co., Ltd. · 2026-07-19 ~ 2036-07-20',
    'b9a56ca5dddc80f4ddb72a821550a0b6605e9575d1d097ae26d68e8ad5527544': 'AnsimSign Document Seal · WildMomentum Co., Ltd. · 2026-10-07 ~ 2036-10-08',
}
# <<< 신뢰 기준
# id-aa-timeStampToken (1.2.840.113549.1.9.16.2.14) 의 DER OID 바이트
TST_OID_TLV = bytes.fromhex('060b2a864886f70d010910020e')

# 묶음에 들어 있어야 하는 파일(이 밖의 파일 = 실패 — 실행 파일·모듈을 심어 검증을 가로채려는 흔적일 수 있다)
BUNDLE_FILES = {
    'README.md', 'manifest.json', 'sealed.pdf', 'verify_bundle.py', 'audit/chain.jsonl',
    'certs/seal-cert.pem', 'certs/kisa-rootca4.pem', 'certs/tsa-signkoreatsa20.pem',
    'anchor/anchor.tst', 'anchor/anchor-preimage.bin', 'audit/chain-v2.jsonl',
}
# 묶음을 받은 뒤 따로 받아 넣는 파일(목록 manifest 에는 없다) — v2 봉투 키 · 선택 공개
LATER_FILES = {'keys/envelope.key'}
DISCLOSURE_RE = re.compile(r'^disclosures/[A-Za-z0-9._-]{1,80}\.json$')
FIELD_DOMAIN = b'ansimsign/v2/field'
# 선택 공개 칸 이름 = 서버 공개 라우트와 같은 패턴(그 밖 = [실패])
DISCLOSURE_FIELD_RE = re.compile(r'^(target_masked|request_ip|user_agent|reason|payload\.[A-Za-z][A-Za-z0-9_]{0,63})$')
# 화면 위조 차단 — 유니코드 범주 단위(손 나열은 빠뜨린다 · 재게이트 하드너 R2): Cc(C0·DEL·C1) · Cf(방향 제어·보이지 않는 서식·BOM·태그) ·
#   Zl/Zp(줄·문단 구분자). 묶음에서 온 문자열과 명령줄 오류만 지나간다.
_ESCAPE_CATEGORIES = frozenset(('Cc', 'Cf', 'Zl', 'Zp'))


def safe(v):
    """묶음에서 온 값을 화면에 찍기 전에 제어·서식 문자를 \\uXXXX(\\UXXXXXXXX)로 — '결과' 줄을 덮어쓰거나 숨기지 못하게(게이트 B 2차)."""
    return ''.join(('\\u%04x' if ord(ch) <= 0xFFFF else '\\U%08x') % ord(ch) if unicodedata.category(ch) in _ESCAPE_CATEGORIES else ch
                   for ch in str(v))
# 압축 도구·운영체제가 덧붙이는 파일(판정과 무관) — 이름 그대로 · macOS AppleDouble('._*') · Windows 다운로드 표시(':Zone.Identifier')
OS_NOISE = {'.DS_Store', 'Thumbs.db', 'desktop.ini'}

# 시각 도장 = 봉인 때 — 도장 시각(genTime)이 봉인 서명 시각(PDF 서명에 서명된 signingTime · 봉인 키 없이 못 바꾼다)의
#   [-ANCHOR_BEFORE_SEC, +ANCHOR_AFTER_SEC] 안이어야 한다. 나중에 새로 받은 도장으로 앵커 값(X)을 바꿔 끼우는 것을 막는다.
#   운영 실측(2026-10-13 · 봉인 3,104건) = 0~2초. 앞쪽 여유 = 서버·TSA 시계 차이 · 뒤쪽 = 서명 뒤 업로드가 느릴 때의 여유
#   (봉인 서버는 서명 → 업로드 → 도장 순서이고 도장이 일시 실패하면 다음 회차에 서명부터 다시 한다 — 상한을 코드가 보장하지는 않는다).
ANCHOR_BEFORE_SEC = 300
ANCHOR_AFTER_SEC = 900
WINDOW_TEXT = '봉인 서명 시각 −%d분 ~ +%d분' % (ANCHOR_BEFORE_SEC // 60, ANCHOR_AFTER_SEC // 60)
SIGNING_TIME_OID_TLV = bytes.fromhex('06092a864886f70d010905')

PASS, FAIL, UNVERIFIED, CONSISTENT = 'pass', 'fail', 'unverified', 'consistent'


class Run:
    def __init__(self, bundle):
        self.bundle = bundle
        # 작업 파일 = 묶음 밖 새 임시 폴더(묶음 안 링크를 따라 검증자 PC 파일을 덮어쓰지 않게 · 게이트 P2)
        self.work = tempfile.mkdtemp(prefix='ansimsign-verify-')
        self.openssl_path = None
        self.results = []
        # 앵커 시각 도장이 OpenSSL 검증을 통과했을 때만 채운다 — 결과 문장 "PDF 해시와 32바이트 값 X 가 시각 T 에 함께 고정됐다"
        self.anchor_x = None
        self.anchor_t = None
        self.anchor_tsa = None
        self.pdf_sha = None
        # v2 봉투 키로 전 기록을 다시 계산해 실패가 없었을 때만 True · 앵커 끝(X)까지 덮은 기록 수
        self.v2_key_ok = False
        self.v2_through = None
        self.v2_key_fp = None

    def path(self, *parts):
        return os.path.join(self.bundle, *parts)

    def exists(self, *parts):
        return os.path.isfile(self.path(*parts))

    def read(self, *parts):
        with open(self.path(*parts), 'rb') as f:
            return f.read()

    def write_work(self, name, data):
        out = os.path.join(self.work, name)
        fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_BINARY', 0), 0o600)
        with os.fdopen(fd, 'wb') as f:
            f.write(data)
        return out

    def record(self, status, label, detail=''):
        # 출력 줄을 먼저 만들고 그 뒤에 결과 목록에 넣는다 — 묶음 값(manifest 의 파일 이름 등)이 문자열이 아니어도 결과 목록이 오염돼
        #   마지막 요약에서 실행 오류(4)로 끝나지 않는다(11차 하드너 P3-1).
        label, detail = safe(label), safe(detail) if detail else ''
        mark = {PASS: '[통과]', FAIL: '[실패]', UNVERIFIED: '[확인 못 함]', CONSISTENT: '[참고]'}[status]
        line = '  ' + mark + ' ' + label + ((' — ' + detail) if detail else '')
        self.results.append((status, label))
        print(line)
        return status in (PASS, CONSISTENT)

    def check(self, label, ok, detail=''):
        return self.record(PASS if ok else FAIL, label, detail)

    def consistent(self, label, ok, detail=''):
        """안심사인이 적은 기록의 자기 일관성(제3자가 다시 계산할 수 없는 값) — 맞으면 [참고](증명 아님 · [통과]로 세지 않는다), 틀리면 실패."""
        return self.record(CONSISTENT if ok else FAIL, label, detail)

    def shown(self, a):
        for base, name in ((self.work, '<작업 폴더>'), (self.bundle, '<묶음>')):
            if os.path.isabs(a) and (a == base or a.startswith(base + os.sep)):
                return name + a[len(base):]
        return a

    def openssl(self, label, args, ok_text):
        cmd = [self.openssl_path] + args
        print('    $ openssl ' + ' '.join(safe(self.shown(a)) for a in args))
        try:
            p = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=self.work)
        except OSError as e:  # 실행 자체가 막힘(백신·앱 제어 등) = 판정 불가 — 변조가 아니다
            return self.record(UNVERIFIED, label, 'OpenSSL 을 실행하지 못했습니다: %s' % e)
        out = (p.stdout + p.stderr).strip()
        lines = out.splitlines()
        ok = p.returncode == 0 and ok_text in out
        detail = next((l for l in lines if ok_text in l), '') if ok else (lines[-1] if lines else '종료 코드 %d' % p.returncode)
        return self.check(label, ok, detail)


# ── DER 읽기(태그 · 값 시작 · 값 끝) ──
def tlv(buf, i, end=None):
    end = len(buf) if end is None else end
    if i + 2 > end:
        raise ValueError('DER 이 잘렸습니다')
    tag = buf[i]
    length = buf[i + 1]
    i += 2
    if length & 0x80:
        n = length & 0x7F
        if n == 0 or n > 4 or i + n > end:
            raise ValueError('지원하지 않는 DER 길이 형식')
        length = int.from_bytes(buf[i:i + n], 'big')
        i += n
    if i + length > end:
        raise ValueError('DER 길이가 감싼 범위보다 깁니다')
    return tag, i, i + length


def children(buf, start, end):
    out = []
    i = start
    while i < end:
        tag, vs, ve = tlv(buf, i, end)
        out.append((tag, i, vs, ve))
        i = ve
    return out


def signed_data(der):
    """ContentInfo → SignedData 의 자식 목록(태그 · TLV 시작 · 값 시작 · 값 끝)."""
    tag, ci_s, ci_e = tlv(der, 0)
    if tag != 0x30:
        raise ValueError('ContentInfo 가 아닙니다')
    ci = children(der, ci_s, ci_e)
    if len(ci) < 2 or ci[1][0] != 0xA0:
        raise ValueError('SignedData 가 없습니다')
    _, _, c0_s, c0_e = ci[1]
    tag, sd_s, sd_e = tlv(der, c0_s, c0_e)
    if tag != 0x30:
        raise ValueError('SignedData 가 아닙니다')
    return children(der, sd_s, sd_e)


def cms_parts(sig):
    """CMS SignedData → (서명값, 서명 안 시각 도장 토큰 또는 None, 서명자 인증서 DER 또는 None, 서명자 수, 서명 시각 epoch 또는 None, 내장 인증서 수)."""
    sd = signed_data(sig)
    certs = []
    for tag, _, vs, ve in sd:
        if tag == 0xA0:                              # [0] certificates
            certs = [sig[c[1]:c[3]] for c in children(sig, vs, ve)]
    si_set = sd[-1]
    if si_set[0] != 0x31:
        raise ValueError('SignerInfos 가 없습니다')
    infos = children(sig, si_set[2], si_set[3])
    if not infos:
        raise ValueError('서명자가 없습니다')
    si = children(sig, infos[0][2], infos[0][3])
    signature, token, signing_time = None, None, None
    for tag, _, vs, ve in si:
        if tag == 0xA0:                              # [0] signedAttrs — signingTime(봉인 키가 서명한 값)
            for _, _, a_vs, a_ve in children(sig, vs, ve):
                attr = children(sig, a_vs, a_ve)
                if sig[attr[0][1]:attr[0][3]] == SIGNING_TIME_OID_TLV:
                    t_tag, _, t_vs, t_ve = children(sig, attr[1][2], attr[1][3])[0]
                    signing_time = parse_time(t_tag, sig[t_vs:t_ve])
        elif tag == 0x04:                            # signature OCTET STRING
            signature = sig[vs:ve]
        elif tag == 0xA1:                            # [1] unsignedAttrs — 시각 도장 속성 정확히 1개 · 값 1개(서버 cms-shape 와 같은 규칙)
            for _, _, a_vs, a_ve in children(sig, vs, ve):
                attr = children(sig, a_vs, a_ve)     # [OID, SET]
                if sig[attr[0][1]:attr[0][3]] == TST_OID_TLV:
                    values = children(sig, attr[1][2], attr[1][3])
                    if token is not None or len(values) != 1:
                        raise ValueError('서명 안 시각 도장 속성이 정확히 1개가 아닙니다')
                    token = sig[values[0][1]:values[0][3]]
    # 서명자 = SignerInfo.sid(issuerAndSerialNumber) 와 발급자·일련번호가 같은 인증서 — 정확히 한 장일 때만
    matches = []
    sid = si[1]
    if sid[0] == 0x30:
        issuer_sn = children(sig, sid[2], sid[3])
        want_issuer = sig[issuer_sn[0][1]:issuer_sn[0][3]]
        want_serial = sig[issuer_sn[1][1]:issuer_sn[1][3]]
        for c in certs:
            _, c_s, c_e = tlv(c, 0)
            tbs = children(c, c_s, c_e)[0]
            f = children(c, tbs[2], tbs[3])
            k = 1 if f[0][0] == 0xA0 else 0          # [0] version 이 있으면 한 칸 밀림
            if c[f[k][1]:f[k][3]] == want_serial and c[f[k + 2][1]:f[k + 2][3]] == want_issuer:
                matches.append(c)
    elif len(certs) == 1:
        matches = certs
    return signature, token, (matches[0] if len(matches) == 1 else None), len(infos), signing_time, len(certs)


def token_signer(tok):
    """시각 도장 토큰의 서명자 인증서 DER — 토큰 안 인증서가 정확히 1장이고 sid 와 원본 바이트로 그 장이 맞을 때만(서버 cms-shape 'tsa' 와 같은
    규칙 · 6차 codex P2-2: 앞에 다른 인증서를 끼운 토큰에서 첫 인증서 이름을 발급 기관으로 찍었다). 아니면 None."""
    try:
        _, _, signer, n_signers, _, n_certs = cms_parts(tok)
    except (ValueError, IndexError):
        return None
    return signer if (n_certs == 1 and n_signers == 1) else None


def parse_time(tag, raw):
    """UTCTime(0x17 'YYMMDDHHMMSSZ') · GeneralizedTime(0x18 'YYYYMMDDHHMMSS[.f]Z') → UTC epoch 초. 못 읽으면 None."""
    try:
        txt = raw.decode('ascii')
    except UnicodeDecodeError:
        return None
    if tag == 0x17:
        m = re.match(r'^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$', txt)
        if not m:
            return None
        yy = int(m.group(1))
        parts = [yy + (2000 if yy < 50 else 1900)] + [int(x) for x in m.groups()[1:]]
    elif tag == 0x18:
        m = re.match(r'^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?Z$', txt)
        if not m:
            return None
        parts = [int(x) for x in m.groups()[:6]]
    else:
        return None
    return calendar.timegm(tuple(parts))


EPOCH0 = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)


def iso(epoch):
    """UTC epoch 초 → 표시 문자열. time.gmtime 은 쓰지 않는다 — Windows 는 1970 전·3000 뒤가 OSError 라, 서명 밖 시각 도장의 시각만 바꾼 묶음이
    판정 대신 실행 오류(4)로 끝났다(10차 codex P2-1). datetime 은 OS 와 무관하게 1~9999년을 다룬다 · parse_time 은 일·시·분·초 범위를 보지 않아
    그 밖 epoch 도 낸다(예: 99991299999999Z) → 아래 except 가 그 자리다(11차 하드너 P3-3)."""
    try:
        t = EPOCH0 + datetime.timedelta(seconds=epoch)
    except (OverflowError, ValueError):
        return '표시 범위 밖(epoch %d)' % epoch
    return '%04d-%02d-%02dT%02d:%02d:%02dZ' % (t.year, t.month, t.day, t.hour, t.minute, t.second)


def token_gen_time(tok):
    """RFC 3161 토큰 → TSTInfo genTime(UTC epoch 초 · 표시 문자열). 못 읽으면 (None, None)."""
    try:
        sd = signed_data(tok)
        encap = children(tok, sd[2][2], sd[2][3])
        e0 = encap[1]
        _, os_s, os_e = tlv(tok, e0[2], e0[3])        # [0] EXPLICIT → OCTET STRING(TSTInfo DER)
        tst = tok[os_s:os_e]
        _, t_s, t_e = tlv(tst, 0)
        f = children(tst, t_s, t_e)
        if f[4][0] != 0x18:                           # genTime GeneralizedTime
            return None, None
        epoch = parse_time(0x18, tst[f[4][2]:f[4][3]])
        return (epoch, iso(epoch)) if epoch is not None else (None, None)
    except (ValueError, IndexError, UnicodeDecodeError):
        return None, None


def pem_to_der_list(text):
    return [base64.b64decode(''.join(b.split())) for b in re.findall(r'-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----', text, re.S)]


def pem_sha256s(data):
    """묶음 안 PEM 파일 → 인증서별 SHA-256 목록. 본문이 깨졌으면(base64 오류) None — 그 항목의 실패로 판정하고 다음 검증을 계속한다
    (실행 오류로 끝내지 않는다 · 10차 codex P3-2)."""
    try:
        return [hashlib.sha256(x).hexdigest() for x in pem_to_der_list(data.decode('ascii', 'replace'))]
    except ValueError:  # binascii.Error 는 ValueError 하위
        return None


PEM_BROKEN = 'PEM 본문을 읽지 못했습니다(깨진 base64)'


def der_to_pem(der):
    b = base64.b64encode(der).decode('ascii')
    return '-----BEGIN CERTIFICATE-----\n' + '\n'.join(b[i:i + 64] for i in range(0, len(b), 64)) + '\n-----END CERTIFICATE-----\n'


def inside(path, base):
    path, base = os.path.normcase(os.path.realpath(path)), os.path.normcase(os.path.realpath(base))
    return path == base or path.startswith(base.rstrip(os.sep) + os.sep)


def openssl_candidates(bundle):
    """PATH 의 openssl 절대 경로 후보(순서대로) — 비절대 경로 · 현재 폴더 그 자체 · 묶음 폴더(와 그 안)는 건너뛴다(게이트 P1:
    Windows 는 이름만 부르면 현재 폴더부터 찾는다 → 모든 호출은 절대 경로). Windows 는 .exe 만(.bat/.cmd = cmd.exe 해석 스크립트)."""
    cwd = os.path.normcase(os.path.realpath(os.getcwd()))
    name = 'openssl.exe' if os.name == 'nt' else 'openssl'
    out = []
    for d in os.environ.get('PATH', '').split(os.pathsep):
        d = d.strip().strip('"')
        if not d or not os.path.isabs(d) or os.path.normcase(os.path.realpath(d)) == cwd or inside(d, bundle):
            continue
        p = os.path.realpath(os.path.join(d, name))
        if os.path.isfile(p) and os.access(p, os.X_OK) and p not in out:
            out.append(p)
    return out


def pick_openssl(r, explicit):
    """(경로 또는 None, 설명) — OpenSSL 3.0 이상 첫 후보. macOS 기본 LibreSSL 은 cms/ts 검증이 달라 쓰지 않는다."""
    if explicit:
        if not os.path.isabs(explicit) or inside(explicit, r.bundle) or not os.path.isfile(explicit):
            return None, '--openssl 은 묶음 폴더 밖의 절대 경로여야 합니다: ' + explicit
        cands = [os.path.realpath(explicit)]
    else:
        cands = openssl_candidates(r.bundle)
    seen = []
    for path in cands:
        try:
            p = subprocess.run([path, 'version'], capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=r.work)
        except OSError as e:  # 실행이 막힌 후보는 건너뛴다
            seen.append('%s(실행 불가: %s)' % (path, e))
            continue
        v = (p.stdout or '').strip()
        m = re.match(r'^OpenSSL (\d+)\.', v)
        if m and int(m.group(1)) >= 3:
            return path, v
        seen.append(v or path)
    if not cands:
        return None, 'OpenSSL 이 없습니다(묶음 폴더 밖 PATH 기준 · --openssl 로 지정할 수 있습니다)'
    return None, 'OpenSSL 3.0 이상이 필요합니다(찾은 것: %s)' % ' · '.join(seen)


def bundle_inventory(r):
    """묶음 안 파일 목록 대조 — 목록 밖 파일·링크 = 실패(묶음은 압축을 푼 그대로여야 한다)."""
    extra, links = [], []
    v2 = r.exists('audit', 'chain-v2.jsonl')  # 키·선택 공개 파일은 v2 묶음에서만 읽는다 — v1 에선 목록 밖 파일
    for root, dirs, files in os.walk(r.bundle, followlinks=False):
        rel_root = os.path.relpath(root, r.bundle).replace(os.sep, '/')
        rel_root = '' if rel_root == '.' else rel_root + '/'
        if rel_root.startswith('__MACOSX/'):
            continue
        for d in list(dirs):
            if os.path.islink(os.path.join(root, d)):
                links.append(rel_root + d)
                dirs.remove(d)
            elif rel_root + d == '__MACOSX':
                dirs.remove(d)
        for f in files:
            rel = unicodedata.normalize('NFC', rel_root + f)
            if os.path.islink(os.path.join(root, f)):
                links.append(rel)
            elif (rel not in BUNDLE_FILES and not (v2 and (rel in LATER_FILES or DISCLOSURE_RE.match(rel)))
                  and f not in OS_NOISE and not f.startswith('._') and not f.endswith(':Zone.Identifier')):
                extra.append(rel)
    r.check('묶음 안에 목록 밖 파일 없음', not extra, ', '.join(sorted(extra)[:10]) if extra else '')
    r.check('묶음 안에 링크 파일 없음', not links, ', '.join(sorted(links)[:10]) if links else '')


class _ArgParser(argparse.ArgumentParser):
    """명령줄 오류(모르는 인자 · 틀린 값)도 정리해 찍는다(재게이트 codex P3-f) — 종료 코드 2 는 그대로."""

    def error(self, message):
        super().error(safe(message))


def main(argv=None):
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding='utf-8', errors='replace')  # 한국어 Windows 에서 결과를 파일로 돌려도 끊기지 않게
        except (AttributeError, ValueError):
            pass
    ap = _ArgParser(prog='verify_bundle.py', description='안심사인 증거 묶음 독립 검증')  # prog 고정 = 실행 파일 이름이 사용법·오류 줄에 그대로 찍히지 않게
    ap.add_argument('bundle', nargs='?', default=os.path.dirname(os.path.abspath(__file__)), help='묶음을 푼 폴더')
    ap.add_argument('--trust-seal-sha256', action='append', default=[], metavar='HEX',
                    help='공식 위치에 공개된 봉인 인증서 SHA-256(64자리) — 기본 목록에 더해 신뢰')
    ap.add_argument('--openssl', metavar='PATH', help='쓸 OpenSSL 실행 파일 절대 경로(묶음 폴더 밖)')
    ap.add_argument('--key', metavar='PATH', help='v2 묶음: 분쟁 때 따로 받은 봉투 키 파일(16진 64자리) — 묶음 안 keys/envelope.key 보다 우선')
    args = ap.parse_args(argv)
    args.key_bytes = None
    if args.key is not None:
        try:
            with open(args.key, 'rb') as f:
                kt = f.read(4097)
        except OSError as e:
            ap.error('--key 파일을 읽지 못했습니다: %s' % e)
        if len(kt) > 4096:
            ap.error('--key 파일이 너무 큽니다(봉투 키 = 16진 64자리)')
        kt = kt[3:] if kt.startswith(b'\xef\xbb\xbf') else kt
        kt = kt.decode('ascii', 'replace').strip().lower()
        if not re.fullmatch(r'[0-9a-f]{64}', kt):
            ap.error('--key 는 16진 64자리(32바이트) 봉투 키 파일입니다')
        args.key_bytes = bytes.fromhex(kt)
    seal_pins = dict(SEAL_CERT_SHA256)
    for h in args.trust_seal_sha256:
        h = h.replace(':', '').strip().lower()
        if not re.fullmatch(r'[0-9a-f]{64}', h):
            ap.error('--trust-seal-sha256 은 SHA-256 64자리 16진수입니다: ' + h)
        seal_pins.setdefault(h, '명령줄에서 신뢰(--trust-seal-sha256)')

    r = Run(os.path.realpath(args.bundle))
    print('안심사인 증거 묶음 검증 (ansimsign-evidence-v1 · v2)')
    print('  묶음 폴더: ' + safe(r.bundle))
    print('  작업 폴더: ' + safe(r.work) + '   (잘라 낸 서명·인증서 — README 의 OpenSSL 명령에 씁니다)')
    with open(os.path.abspath(__file__), 'rb') as f:
        print('  이 스크립트 SHA-256(스스로 계산한 값 — 근거 아님, 따로 계산해 공식 목록과 대조): ' + hashlib.sha256(f.read()).hexdigest())
    print('  신뢰 기준(아래 값을 ' + OFFICIAL_URL + ' · KISA 공식 사이트와 대조하세요):')
    print('    KISA RootCA 4        ' + KISA_ROOTCA4_SHA256)
    for h, note in seal_pins.items():
        print('    안심사인 봉인 인증서  ' + h + '  (' + note + ')')
    if not os.path.isdir(r.bundle):
        r.check('묶음 폴더 있음', False, r.bundle)
        return finish(r)
    if inside(os.path.abspath(__file__), r.bundle) and not sys.flags.isolated:
        print('  ※ 묶음 안의 스크립트로 실행했습니다. 공식 위치에서 받은 스크립트를 묶음 밖에 두고 -I 로 실행하는 것이 안전합니다.')

    kisa_der = pem_to_der_list(KISA_ROOTCA4_PEM)
    if len(kisa_der) != 1 or hashlib.sha256(kisa_der[0]).hexdigest() != KISA_ROOTCA4_SHA256:
        r.check('스크립트 신뢰 기준 자체 점검(KISA 루트 = 고정 지문)', False, '스크립트가 손상됐습니다 — 공식 위치에서 다시 받으세요')
        return finish(r)
    root_file = r.write_work('trusted-kisa-rootca4.pem', KISA_ROOTCA4_PEM.encode('ascii'))

    print('\n1) 묶음 파일 (목록 밖 파일 · manifest.json — 서명되지 않은 목록이라 파일 일관성만 봅니다)')
    bundle_inventory(r)
    manifest = {}
    manifest_read = False  # 목록을 모양까지 정상으로 읽었을 때만 True — 그때만 "목록이 빠뜨린 것" 을 실패로 본다(없으면 확인 못 함 · 깨졌으면 읽기 실패)
    if not r.exists('manifest.json'):
        r.record(UNVERIFIED, 'manifest.json 없음')
    else:
        try:
            manifest = json.loads(r.read('manifest.json'))
            # `or {}` 를 쓰지 않는다 — 빈 배열·null·칸 없음이 빈 목록으로 바뀌어 모양 검사를 건너뛰고 파일 대조가 조용히 사라졌다(12차 codex P3-1).
            #   서버(evidence-bundle-service)는 항상 "이름 → SHA-256" 목록을 싣는다.
            listed = manifest.get('files')
            if not isinstance(listed, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in listed.items()):
                raise TypeError('files 가 "이름 → SHA-256 문자열" 목록이 아닙니다')
            for name in sorted(listed):
                if name not in BUNDLE_FILES:
                    r.check(name, False, '목록 밖 이름입니다')
                elif not r.exists(*name.split('/')):
                    r.check(name, False, '목록에 있는데 파일이 없습니다')
                else:
                    r.consistent(name, hashlib.sha256(r.read(*name.split('/'))).hexdigest() == listed[name])
            # 목록이 묶음 파일을 빠짐없이 덮는가 — 빈 목록 · 일부만 나열이면 그 파일의 대조 [참고] 가 [실패] 없이 사라졌다(13차 하드너 P3).
            #   서버는 README·manifest 를 뺀 묶음 파일 전부를 싣는다(앵커 파일은 앵커가 있을 때만 있다 = 파일이 있으면 목록에도 있어야 한다).
            for name in sorted(BUNDLE_FILES - {'README.md', 'manifest.json'} - set(listed)):
                if r.exists(*name.split('/')):
                    r.consistent(name, False, '묶음에 있는데 목록에 없습니다')
            manifest_read = True
        except (ValueError, AttributeError, TypeError, RecursionError) as e:  # RecursionError = 깊게 중첩된 JSON(10차 codex P3-1)
            r.check('manifest.json 읽기', False, '%s: %s' % (type(e).__name__, e))
            manifest = {}
    if not isinstance(manifest, dict):
        manifest = {}

    if not r.exists('sealed.pdf'):
        r.check('sealed.pdf 있음', False)
        return finish(r)
    pdf = r.read('sealed.pdf')
    pdf_sha = hashlib.sha256(pdf).hexdigest()
    r.pdf_sha = pdf_sha
    print('  sealed.pdf SHA-256: ' + pdf_sha)
    if manifest_read:  # 서버는 봉인 기록 해시를 항상 싣는다 — 없거나 문자열이 아니면 [실패](없다고 행을 건너뛰지 않는다 · 13차 하드너 P3)
        sealed_hash = manifest.get('sha256Sealed')
        r.consistent('sealed.pdf SHA-256 = 목록의 봉인 기록 해시', isinstance(sealed_hash, str) and pdf_sha == sealed_hash.lower(),
                     '' if isinstance(sealed_hash, str) else '목록에 봉인 기록 해시가 없습니다')

    print('\n2) PDF 전자서명 영역')
    text = pdf.decode('latin1')
    # 자리수 상한 20 — 무제한이면 4,301자리 이상에서 int() 가 ValueError(파이썬 정수 문자열 상한) → 서명 영역 변조가 [실패] 대신 실행 오류(4)였다
    #   (11차 하드너 P3-2). 21자리 이상은 매치되지 않아 "정확히 1개" 가 [실패] 한다.
    ranges = re.findall(r'/ByteRange\s*\[\s*(\d{1,20})\s+(\d{1,20})\s+(\d{1,20})\s+(\d{1,20})\s*\]', text)
    if not r.check('서명 영역(/ByteRange) 정확히 1개', len(ranges) == 1, '%d개' % len(ranges)):
        return finish(r)
    a, b, c, d = map(int, ranges[0])
    covered = a == 0 and b < c and c + d == len(pdf) and pdf[b:b + 1] == b'<' and pdf[c - 1:c] == b'>'
    if not r.check('서명 영역이 파일 전체를 덮음 (서명값 칸만 제외 · 뒤에 덧붙인 내용 없음)', covered):
        return finish(r)
    hex_body = text[b + 1:c - 1]
    try:
        raw = bytes.fromhex(hex_body)
        _, _, sig_end = tlv(raw, 0)
        if raw[sig_end:].strip(b'\x00'):
            raise ValueError('서명 뒤에 0 이 아닌 바이트')
        sig = raw[:sig_end]
        signature, pdf_token, signer_der, n_signers, signing_time, _ = cms_parts(sig)
    except (ValueError, IndexError, TypeError) as e:
        r.check('서명(CMS) 구조 해석', False, str(e))
        return finish(r)
    r.check('서명자 정확히 1명', n_signers == 1, '%d명' % n_signers)
    print('  봉인 서명 시각(PDF 서명에 서명된 값): ' + (iso(signing_time) if signing_time is not None else '없음'))
    if signer_der is None:
        r.check('봉인 인증서 = 안심사인 공개 지문', False, '서명 안에서 서명자 인증서를 정확히 한 장 찾지 못했습니다')
        return finish(r)
    signer_sha = hashlib.sha256(signer_der).hexdigest()
    seal_ok = r.check('봉인 인증서 = 안심사인 공개 지문', signer_sha in seal_pins,
                      signer_sha + ('  (' + seal_pins[signer_sha] + ')' if signer_sha in seal_pins else '  — 신뢰 목록에 없는 인증서입니다'))
    if r.exists('certs', 'seal-cert.pem'):
        bundled = pem_sha256s(r.read('certs', 'seal-cert.pem'))
        r.consistent('묶음 안 certs/seal-cert.pem = 서명자 인증서', bundled == [signer_sha], PEM_BROKEN if bundled is None else '')
    content_file = r.write_work('signed_content.bin', pdf[a:a + b] + pdf[c:c + d])
    sig_file = r.write_work('pdf-signature.der', sig)
    sigval_file = r.write_work('pdf-signature-value.bin', signature or b'')
    signer_file = r.write_work('trusted-seal-cert.pem', der_to_pem(signer_der).encode('ascii'))
    pdf_ts_file = None
    if pdf_token is not None:
        pdf_ts_file = r.write_work('pdf-timestamp.tst', pdf_token)
        print('  서명 안 시각 도장 토큰: 있음')
    else:
        print('  서명 안 시각 도장 토큰: 없음 (이 봉인본의 시각 증명은 3단계 앵커 시각 도장입니다)')

    print('\n3) 시각 도장 앵커 ↔ 이 PDF')
    has_anchor = r.exists('anchor', 'anchor.tst') and r.exists('anchor', 'anchor-preimage.bin')
    pre = None
    if not has_anchor:
        r.record(UNVERIFIED, '시각 도장 앵커(anchor/anchor.tst · anchor-preimage.bin)', '없습니다 — 이 PDF 해시를 외부 기관이 시각과 함께 고정한 증명이 없습니다')
    else:
        pre = r.read('anchor', 'anchor-preimage.bin')
        r.check('앵커 원문 = 64바이트 (값 X 32 + PDF 해시 32)', len(pre) == 64)
        r.check('앵커 원문 뒤 32바이트 = sealed.pdf SHA-256', pre[32:].hex() == pdf_sha)
    if r.exists('certs', 'kisa-rootca4.pem'):
        bundled_root = pem_sha256s(r.read('certs', 'kisa-rootca4.pem'))
        r.check('묶음 안 certs/kisa-rootca4.pem = KISA RootCA 4 공식 지문', bundled_root == [KISA_ROOTCA4_SHA256],
                '' if bundled_root == [KISA_ROOTCA4_SHA256] else
                (PEM_BROKEN if bundled_root is None else '다른 인증서입니다(위조 의심)') + ' — 검증에는 스크립트에 고정된 KISA 루트만 씁니다')

    print('\n4) 감사 기록 목록 (audit/chain.jsonl · [참고] = 안심사인이 적은 기록의 자기 일관성 — 제3자 증명이 아닙니다)')
    if not r.exists('audit', 'chain.jsonl'):
        r.check('audit/chain.jsonl 있음', False)
    else:
        try:
            events = [json.loads(line) for line in r.read('audit', 'chain.jsonl').decode('utf-8').split('\n') if line.strip()]
            low = lambda v: v.lower() if isinstance(v, str) else v  # noqa: E731
            seqs = [e['seq'] for e in events]
            r.consistent('일련번호 1부터 빠짐없음', seqs == list(range(1, len(events) + 1)))
            linked = all(low(events[i]['prevHash']) == (low(events[i - 1]['hash']) if i else None) for i in range(len(events)))
            r.consistent('각 기록의 "앞 기록 해시" 표기가 바로 앞 기록과 같음', linked)
            sealed = [i for i, e in enumerate(events) if e.get('eventType') == 'envelope_sealed']
            r.consistent('봉인 기록(envelope_sealed) 정확히 1개', len(sealed) == 1, '%d개' % len(sealed))
            if pre is not None and len(sealed) == 1:
                k = sealed[0]
                tip = pre[:32].hex()
                t = next((i for i, e in enumerate(events) if low(e['hash']) == tip), None)
                ok = t is not None and t < k
                # 체인 끝을 읽은 뒤 봉인을 확정하기 전에 붙은 기록(휴지통 이동 등)은 앵커 범위 밖이다 — 건수를 그대로 알린다
                r.consistent('앵커 앞 32바이트 값 = 목록의 봉인 기록 앞 기록 해시 표기', ok,
                             ('목록 %d번째 기록의 해시 표기가 앵커 값과 같음 · 그 뒤 봉인 처리 중 기록 %d건 · 봉인 뒤 기록 %d건은 앵커 범위 밖'
                              % (t + 1, k - t - 1, len(events) - k - 1)) if ok else '앵커 앞 32바이트 값이 목록의 봉인 기록 앞에 없습니다')
        except (ValueError, KeyError, TypeError, AttributeError, RecursionError) as e:  # RecursionError = 깊게 중첩된 JSON(10차 codex P3-1)
            r.check('audit/chain.jsonl 읽기', False, '%s: %s' % (type(e).__name__, e))
    print('  ※ [참고] 의 뜻: v1 묶음의 감사 기록 해시·앞 기록 해시는 안심사인이 보관하는 키(HMAC-SHA256)로 계산한 값을 안심사인이 적은 것이라,')
    print('    이 묶음만으로는 다시 계산할 수 없습니다. 앵커 값만 맞춰 지어낸 목록도 위 [참고] 를 모두 통과합니다 —')
    print('    목록이 스스로 어긋나지 않는다는 뜻일 뿐, 앵커 값이 실제 감사 기록 체인의 끝이라는 증명이 아닙니다.')
    if r.exists('audit', 'chain-v2.jsonl'):
        audit_v2(r, args, manifest, manifest_read, pre)
    elif manifest_read and manifest.get('format') == 'ansimsign-evidence-v2':
        r.check('목록 형식 v2 = audit/chain-v2.jsonl 있음', False, 'v2 묶음인데 v2 감사 기록 파일이 없습니다')
    elif args.key_bytes is not None:
        print('  ※ --key 를 받았지만 v1 묶음이라 쓰지 않습니다(v1 감사 기록은 봉투별 키로 서명되지 않았습니다)')

    print('\n5) 전자서명 · 시각 도장 (OpenSSL)')
    r.openssl_path, why = pick_openssl(r, args.openssl)
    if not r.openssl_path:
        print('  ' + safe(why) + '. README.md 의 명령을 OpenSSL 3.0 이상이 있는 곳에서 실행하세요.')
        r.record(UNVERIFIED, 'PDF 전자서명 (' + why + ')')
        if has_anchor:
            r.record(UNVERIFIED, '앵커 시각 도장 (' + why + ')')
        if pdf_token is not None:
            r.record(UNVERIFIED, '서명 안 시각 도장 (' + why + ')')
        return finish(r)
    print('  ' + safe(why) + ' · 위치 ' + safe(r.openssl_path))
    # 시각 도장 검증에 묶음의 TSA 인증서 파일(-untrusted)을 주지 않는다 — OpenSSL 은 -untrusted 인증서를 토큰 안 인증서보다 먼저 서명자로 고른다.
    #   그러면 실제 검증은 묶음 파일로 하고 화면엔 토큰 안 인증서(바꿔 끼운 미끼일 수 있다)의 주체를 찍게 된다(7차 하드너·codex P2).
    #   토큰 안 인증서 1장으로만 검증해야 "표시한 발급 기관 = OpenSSL 이 검증한 인증서" 가 된다(SignKoreaTSA20 은 KISA 루트 직발급이라 체인이 선다).
    attime = None
    if has_anchor:
        anchor_tok = r.path('anchor', 'anchor.tst')
        epoch, shown = token_gen_time(r.read('anchor', 'anchor.tst'))
        at = ['-attime', str(epoch)] if epoch is not None else []
        if r.openssl('앵커 시각 도장 (발급 인증서 ← KISA RootCA 4 고정 지문)',
                     ['ts', '-verify', '-in', anchor_tok, '-token_in', '-data', r.path('anchor', 'anchor-preimage.bin'),
                      '-CAfile', root_file] + at, 'Verification: OK'):
            attime = epoch
            print('    시각 도장 시각(UTC): ' + (shown or '읽지 못함') + (' (epoch %d)' % epoch if epoch is not None else '') +
                  '   · 목록의 봉인 기록 시각(참고): ' + safe(manifest.get('sealedAt')))
            anchor_signer = token_signer(r.read('anchor', 'anchor.tst'))
            if r.check('앵커 시각 도장 토큰 = 서명자 인증서 정확히 1장', anchor_signer is not None,
                       '' if anchor_signer is not None else '토큰 안 인증서가 1장이 아니거나 서명자와 맞지 않습니다 — 발급 기관을 정할 수 없습니다'):
                r.anchor_tsa = tsa_subject(r, anchor_signer, 'anchor')
                # 묶음 안 TSA 인증서 파일 = 토큰 서명자(9차 · -untrusted 를 뺀 뒤 이 파일이 어떤 검사에도 안 쓰여 다른 KISA 발급 인증서로 바꿔도 통과했다)
                if r.exists('certs', 'tsa-signkoreatsa20.pem'):
                    bundled_tsa = pem_sha256s(r.read('certs', 'tsa-signkoreatsa20.pem'))
                    r.consistent('묶음 안 certs/tsa-signkoreatsa20.pem = 앵커 시각 도장 서명자 인증서', bundled_tsa == [hashlib.sha256(anchor_signer).hexdigest()],
                                 PEM_BROKEN if bundled_tsa is None else '')
            if pre is not None and len(pre) == 64 and shown:
                r.anchor_x, r.anchor_t = pre[:32].hex(), shown
            time_window(r, '앵커 시각 도장 = 봉인 때(%s)' % WINDOW_TEXT, epoch, signing_time)
    if pdf_ts_file is not None:
        epoch2, shown2 = token_gen_time(pdf_token)
        at2 = ['-attime', str(epoch2)] if epoch2 is not None else []
        if r.openssl('서명 안 시각 도장 (대상 = 서명값 · KISA RootCA 4 고정 지문)',
                     ['ts', '-verify', '-in', pdf_ts_file, '-token_in', '-data', sigval_file, '-CAfile', root_file] + at2,
                     'Verification: OK'):
            print('    시각 도장 시각(UTC): ' + (shown2 or '읽지 못함'))
            pdf_signer = token_signer(pdf_token)
            if r.check('서명 안 시각 도장 토큰 = 서명자 인증서 정확히 1장', pdf_signer is not None,
                       '' if pdf_signer is not None else '토큰 안 인증서가 1장이 아니거나 서명자와 맞지 않습니다'):
                tsa_subject(r, pdf_signer, 'pdf')
            time_window(r, '서명 안 시각 도장 = 봉인 때(%s)' % WINDOW_TEXT, epoch2, signing_time)
            if attime is None:
                attime = epoch2
    if seal_ok:
        at3 = ['-attime', str(attime)] if attime is not None else []
        r.openssl('PDF 전자서명 (서명자 = 고정 지문 봉인 인증서' + (' · 시각 도장 시각 기준)' if attime is not None else ')'),
                  ['cms', '-verify', '-binary', '-inform', 'DER', '-in', sig_file, '-content', content_file,
                   '-CAfile', signer_file, '-no-CApath', '-no-CAstore', '-partial_chain', '-purpose', 'any'] + at3 + ['-out', os.devnull],
                  'Verification successful')
    return finish(r)


def load_key(r, args):
    """봉투 키 — --key(묶음 밖 · main 에서 형식 확인) 우선 · 없으면 묶음 안 keys/envelope.key(형식이 틀리면 [실패]). 반환 (키 | None, 출처)."""
    if args.key_bytes is not None:
        if r.exists('keys', 'envelope.key'):
            print('  ※ 묶음 안 keys/envelope.key 는 쓰지 않습니다(--key 가 우선)')
        return args.key_bytes, '--key ' + args.key
    if not r.exists('keys', 'envelope.key'):
        return None, None
    raw = r.read('keys', 'envelope.key')
    big = len(raw) > 4096  # BOM 을 떼기 전 원본 크기(--key 와 같은 기준 · 재게이트 codex P3-b)
    raw = raw[3:] if raw.startswith(b'\xef\xbb\xbf') else raw
    text = '' if big else raw.decode('ascii', 'replace').strip().lower()
    if not r.check('봉투 키 형식 = 16진 64자리(32바이트)', re.fullmatch(r'[0-9a-f]{64}', text) is not None, 'keys/envelope.key'):
        return None, None
    return bytes.fromhex(text), 'keys/envelope.key'


def field_target(canon, field):
    """canonical 안 그 칸의 값(커밋먼트) — payload.<키> 는 payload 안."""
    if field.startswith('payload.'):
        p = canon.get('payload')
        return p.get(field[8:]) if isinstance(p, dict) else None
    return canon.get(field)


def audit_v2(r, args, manifest, manifest_read, pre):
    """v2 감사 기록 — 키가 있으면 전 기록 HMAC 재계산([통과]) · 없으면 자기 일관성([참고]) · 선택 공개 대조."""
    print('\n4-2) 감사 기록 v2 (audit/chain-v2.jsonl · 봉투별 키 HMAC-SHA256 · 개인정보 칸 = 커밋먼트)')
    key, src = load_key(r, args)
    # 앵커 원문(64바이트)이 있을 때만 키 판정 = [통과] — 그 시각 도장 검증은 뒤(5)에서 하고, 실패·확인 못 함이면 전체 결과가 실패·부분 검증이라
    #   '키로 다시 계산한 체인' 결과 문장이 나오지 않는다(finish 는 전부 통과일 때만 증명 범위를 말한다).
    anchored = pre is not None and len(pre) == 64
    keyed = key is not None and anchored
    if key is not None:
        r.v2_key_fp = hashlib.sha256(key).hexdigest()
        print('  제공된 봉투 키: ' + safe(src) + ' · 키 SHA-256 지문 ' + r.v2_key_fp)
        print('    스크립트는 키를 누가 줬는지 확인하지 않습니다 — 이 지문이 안심사인이 키와 함께 공식 경로로 알려 준 지문과 같은지 대조하세요.')
        if not anchored:
            print('  ※ 시각 도장 앵커가 없어 키 판정은 [참고]입니다 — 키·체인이 봉인 때 고정됐다는 근거가 없습니다.')
    else:
        print('  봉투 키: 없음 — 아래 대조는 [참고](자기 일관성)입니다. 키 파일이 있으면 각 기록 해시를 직접 다시 계산합니다.')
    judge = r.check if keyed else r.consistent
    try:
        rows = [json.loads(line) for line in r.read('audit', 'chain-v2.jsonl').decode('utf-8').split('\n') if line.strip()]
        canons = [json.loads(e['canonical']) for e in rows]
        if not all(isinstance(e, dict) and isinstance(e.get('canonical'), str) and isinstance(e.get('hash'), str) for e in rows) \
                or not all(isinstance(c, dict) for c in canons):
            raise TypeError('기록 형식이 { seq, hash, canonical(문자열) } 가 아닙니다')
        n = len(rows)
        if key is not None:
            bad = [i + 1 for i, e in enumerate(rows)
                   if not hmac.compare_digest(hmac.new(key, e['canonical'].encode('utf-8'), hashlib.sha256).hexdigest(), e['hash'].lower())]
            judge('감사 기록 해시 %d건 = 봉투 키로 다시 계산한 값' % n, not bad and n > 0,
                    ('어긋난 기록 일련번호 ' + ', '.join(map(str, bad[:10]))) if bad else '')
        judge('v2 기록 일련번호 1부터 빠짐없음', [e.get('seq') for e in rows] == list(range(1, n + 1))
              and [c.get('seq') for c in canons] == list(range(1, n + 1)))
        judge('v2 각 기록 안의 "앞 기록 해시" = 바로 앞 기록 해시(첫 기록 = 없음)',
              all(canons[i].get('prev_hash') == (rows[i - 1]['hash'] if i else None) for i in range(n)))
        judge('v2 기록 방식 표시(scheme = v2)', all(c.get('scheme') == 'v2' for c in canons))
        env_id = manifest.get('envelopeId') if manifest_read else None
        if isinstance(env_id, str):
            judge('v2 기록의 봉투 ID = 목록의 봉투 ID', all(str(c.get('envelope_id')).lower() == env_id.lower() for c in canons))
        elif manifest_read:
            r.check('목록(manifest.json)에 봉투 ID(envelopeId) 있음', False, 'v2 묶음은 봉투 ID 로 기록과 묶음을 맞춥니다')
        if r.exists('audit', 'chain.jsonl'):
            try:
                ev = [json.loads(line) for line in r.read('audit', 'chain.jsonl').decode('utf-8').split('\n') if line.strip()]
                same = len(ev) == n and all(
                    ev[i].get('hash') == rows[i]['hash'] and ev[i].get('prevHash') == canons[i].get('prev_hash')
                    and ev[i].get('eventType') == canons[i].get('event_type') and ev[i].get('occurredAt') == canons[i].get('occurred_at')
                    for i in range(n))
                judge('audit/chain.jsonl 표기 = v2 기록(해시 · 앞 기록 해시 · 종류 · 시각)', same)
            except (ValueError, KeyError, TypeError, AttributeError, RecursionError) as e:
                r.check('audit/chain.jsonl 읽기(v2 대조)', False, '%s: %s' % (type(e).__name__, e))
        sealed = [i for i, c in enumerate(canons) if c.get('event_type') == 'envelope_sealed']
        if pre is not None and len(pre) == 64 and len(sealed) == 1:
            k = sealed[0]
            tip = pre[:32].hex()
            t = next((i for i, e in enumerate(rows) if e['hash'].lower() == tip), None)
            ok = t is not None and t < k
            if judge('앵커 앞 32바이트 값 X = v2 체인의 봉인 기록 앞 기록 해시' + (' (키로 다시 계산한 체인)' if key is not None else ''), ok,
                     ('%d번째 기록 · 봉인 처리 중 기록 %d건 · 봉인 뒤 기록 %d건은 앵커 범위 밖' % (t + 1, k - t - 1, n - k - 1)) if ok else
                     '앵커 앞 32바이트 값이 v2 체인의 봉인 기록 앞에 없습니다') and keyed:
                r.v2_through = t + 1
        if keyed and not any(s == FAIL for s, _ in r.results):
            r.v2_key_ok = True
        disclosures(r, canons, keyed)
    except (ValueError, KeyError, TypeError, AttributeError, RecursionError, UnicodeDecodeError) as e:
        r.check('audit/chain-v2.jsonl 읽기', False, '%s: %s' % (type(e).__name__, e))


def disclosures(r, canons, keyed):
    """선택 공개(disclosures/*.json · 분쟁 때 안심사인이 따로 공개) — 한 기록 한 칸의 salt + 값 바이트 → 커밋먼트 대조."""
    folder = r.path('disclosures')
    if not os.path.isdir(folder):
        return
    try:
        names = sorted(f for f in os.listdir(folder) if DISCLOSURE_RE.match('disclosures/' + f))
    except OSError as e:
        r.check('선택 공개 폴더(disclosures/) 읽기', False, '%s: %s' % (type(e).__name__, e))
        return
    if not names:
        return
    print('  선택 공개 %d건 (값 = 공개받은 원문 · 커밋먼트와 같으면 그 칸의 원문이 이 값이다)' % len(names))
    for name in names:
        path = r.path('disclosures', name)
        if os.path.islink(path) or not os.path.isfile(path):
            r.check('선택 공개 %s' % name, False, '일반 파일이 아닙니다')
            continue
        try:
            d = json.loads(r.read('disclosures', name))
            seq, field = d['seq'], d['field']
            if isinstance(seq, bool) or not isinstance(seq, int) or not isinstance(field, str) or not DISCLOSURE_FIELD_RE.match(field):
                raise ValueError('seq(정수) · field(공개 가능한 칸 이름) 형식이 아닙니다')
            inside = r.v2_through is not None and seq <= r.v2_through
            judge = r.check if keyed and inside else r.consistent
            salt = bytes.fromhex(str(d['saltHex']))
            val = base64.b64decode(str(d['valueB64']), validate=True)
            if not (1 <= seq <= len(canons)) or len(salt) != 16 or val[:1] not in (b's', b'j'):
                raise ValueError('일련번호·salt 16바이트·값 표지(s/j) 중 하나가 맞지 않습니다')
            c = hashlib.sha256(FIELD_DOMAIN + b'\x00' + field.encode('utf-8') + b'\x00' + salt + val).hexdigest()
            shown = val[1:].decode('utf-8', 'replace')
            judge('선택 공개 %s — 기록 %d · %s = 커밋먼트' % (name, seq, field), c == field_target(canons[seq - 1], field),
                  '값(%s): %s' % ('문자열' if val[:1] == b's' else 'JSON', shown[:200]) + ('' if inside or not keyed else ' · 앵커 범위 밖 기록'))
        except (ValueError, KeyError, TypeError, AttributeError, RecursionError, UnicodeDecodeError, OverflowError, OSError) as e:
            r.check('선택 공개 %s 읽기' % name, False, '%s: %s' % (type(e).__name__, e))


def time_window(r, label, gen, signing):
    """시각 도장 시각이 봉인 서명 시각 창 안인지 — 서명 시각이 없으면 확인 못 함(나중에 받은 도장인지 가릴 수 없다)."""
    if gen is None or signing is None:
        r.record(UNVERIFIED, label, '시각을 읽지 못했습니다')
        return
    d = gen - signing
    r.check(label, -ANCHOR_BEFORE_SEC <= d <= ANCHOR_AFTER_SEC, '서명 %s · 도장 %s · 차 %+d초' % (iso(signing), iso(gen), d))


def tsa_subject(r, signer_der, tag):
    """시각 도장 서명자 인증서(token_signer 가 고른 그 1장)의 주체 = 발급 기관 표시(검증은 위 ts -verify 가 했다)."""
    path = r.write_work('tsa-signer-%s.der' % tag, signer_der)
    try:
        # -nameopt 명시 — 기본 이름 형식이 OpenSSL 버전마다 다르다(3.0 = "C = KR, O = …" · 3.5 = "C=KR, O=…" · 트렁크 CI Linux 실측).
        #   같은 인증서가 PC 마다 다르게 찍히면 공개 안내·대조가 흔들린다 → 형식을 고정한다(표시 전용 · 판정은 위 ts -verify).
        p = subprocess.run([r.openssl_path, 'x509', '-inform', 'DER', '-in', path, '-noout', '-subject', '-nameopt', 'utf8,sep_comma_plus_space'],
                           capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=r.work)
    except OSError:
        return None
    subj = [line.split('=', 1)[1].strip() for line in (p.stdout or '').splitlines() if line.startswith('subject')]
    if subj:
        print('    시각 도장 발급 인증서: ' + safe(subj[0]))
        return safe(subj[0])
    return None


def finish(r):
    failed = [label for status, label in r.results if status == FAIL]
    missing = [label for status, label in r.results if status == UNVERIFIED]
    print('')
    if failed:
        print('결과: 실패 — %d건: %s' % (len(failed), ', '.join(failed)))
        if missing:
            print('  확인하지 못한 것: ' + ', '.join(missing))
        return 1
    if missing:
        print('결과: 부분 검증 — 실패한 항목은 없지만 확인하지 못한 것이 있습니다.')
        print('  확인하지 못한 것: ' + ', '.join(missing))
        return 3
    passed = sum(1 for status, _ in r.results if status == PASS)
    refs = sum(1 for status, _ in r.results if status == CONSISTENT)
    print('결과: 전부 통과 — [통과] %d건(제3자가 이 PC 에서 직접 다시 계산·검증) · [참고] %d건(안심사인이 적은 기록의 자기 일관성 · 증명 아님)' % (passed, refs))
    print('  이 묶음이 증명하는 것: 이 PDF(SHA-256 %s)와 32바이트 값 X 가 시각 T 에 함께 고정됐다' % r.pdf_sha)
    print('    X = ' + str(r.anchor_x))
    print('    T = ' + str(r.anchor_t) + ' UTC · 시각 도장 발급: ' + str(r.anchor_tsa or '표시 못 함') + ' (KISA RootCA 4 체계 · ' + WINDOW_TEXT + ' 안)')
    print('    그리고 이 PDF 의 봉인 서명은 공개 지문의 안심사인 봉인 인증서로 검증되고, 봉인 뒤 바뀌지 않았다.')
    if r.v2_key_ok and r.v2_through:
        print('    또 X 는 제공된 봉투 키로 다시 계산한 감사 기록 체인의 봉인 기록 앞 끝이다 — 1번부터 %d번 기록(개인정보 칸은 커밋먼트)이' % r.v2_through)
        print('    그 시각에 이 PDF 와 함께 고정됐다(기록 하나라도 바꾸면 해시가 달라져 X 와 이어지지 않는다).')
        print('  증명하지 않는 것: 앵커 뒤 기록(봉인 처리 중·봉인 뒤)의 내용(키를 가진 사람은 새 기록을 지어낼 수 있다) · 커밋먼트 칸의 원문(선택 공개가 없는 칸) ·')
        print('              이 키와 체인이 안심사인 것이라는 귀속(제공된 키 지문 %s 을 안심사인이 알려 준 지문과 대조) ·' % r.v2_key_fp)
        print('              인증서 폐지 여부(CRL·OCSP) ·')
    else:
        print('  증명하지 않는 것: X 가 실제 감사 기록 체인의 끝이라는 것과 각 기록의 내용(키 필요 · 위 [참고]) · 인증서 폐지 여부(CRL·OCSP) ·')
    print('              앵커를 누가 요청했는지(' + WINDOW_TEXT + ' 안에 다른 공인 시각 도장 기관에서 받은 도장도 통과합니다)')
    print('  (이 판정은 이 스크립트의 SHA-256 과 신뢰 기준 값이 ' + OFFICIAL_URL + ' 와 같을 때만 의미가 있습니다)')
    return 0


if __name__ == '__main__':
    try:
        sys.exit(main())
    except SystemExit:
        raise
    except Exception as e:  # noqa: BLE001 — 예상 못 한 오류는 "실패(변조)"가 아니라 "검증을 끝내지 못함"(4)
        print('\n결과: 검증을 끝내지 못했습니다(실행 오류 · 변조 판정 아님) — %s: %s' % (type(e).__name__, safe(e)))
        sys.exit(4)
