#!/usr/bin/env python3
"""odp-local-sign — ODP 声明的本地密钥、签名与预检工具（单文件，仅依赖 cryptography）

自证闸（先跑它，再谈信任任何输出）：
  selftest                    用规范 §12.1 的固定向量自检：规范化字节 / 哈希 / 验签 /
                              篡改必失败。genkey·sign·publish 前自动跑，不过即拒绝执行。
                              与 odp-local-sign.mjs 内嵌同一份向量，两版必须给出同一 726 字节。

密钥生命周期（备份仪式在这里，不在索引侧）：
  genkey   <priv.pem> [口令]      生成密钥 + 口令加密副本 + RECOVERY 说明 + 指纹（三件套）
  pubkey   <priv.pem>            打印 base64 原始公钥（用于登记宿主文档）
  fingerprint <priv.pem|pubkey>  打印公钥指纹（与索引回执对账，确认"备份的就是这把钥匙"）
  encrypt  <priv.pem> <out.enc> [口令]   对已有密钥补做加密备份
  decrypt  <in.enc> <out.pem> [口令]     从加密副本恢复私钥
  sign     <priv.pem> <key_id> <in.ndjson> <out.ndjson>   为声明追加签名
  publish  --handle H --in decls.ndjson [--key anchor.pem] [--base URL] [--dry-run]
                                 一条命令走完整发布循环：自动补齐 source_uri/human_uri/时间戳 与 key_id
                                 → 本地签名 → preflight（不过就不投递）→ 投递 → 四行回执
  verify   <key_id> <in.ndjson>           用已登记公钥本地验签
  check    <handle> [base]                对账：本地公钥 == 索引上该 handle 登记的公钥
  keys     <handle> grant  --anchor a.pem --delegate sub.pem [--key-id K]
                                         [--granted publish,withdraw] [--days 365] [--dry-run]
                                         anchor 签发一把**可吊销的工作钥**给 Agent（§6.4-20）：
                                         子钥能发布/撤回，不能换根/关闭/迁移/管理钥
  keys     <handle> revoke --anchor a.pem --key-id K          吊销即止血，历史声明不受影响
  preflight <in.ndjson> [handle]          调索引预检（不落库）

签名约定（规范 §12 / §6.4-7）：对"除 signature 外整条记录"的 RFC 8785(JCS) 规范化
**字节**直接做 Ed25519 签名。**不要**先做 SHA-256 预散列（Ed25519 内部已含散列）。

⚠️ 私钥丢失＝该托管源的写入/关闭/迁移权永久失去，索引不托管也不重置私钥。
   genkey 产出的三件套请分别存放：加密副本 + 口令 **不要**存在同一台机器同一目录。
"""
import base64
import hashlib
import json
import math
import os
import stat
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

try:
    from cryptography.hazmat.primitives import serialization as ser
    from cryptography.hazmat.primitives.asymmetric.ed25519 import (
        Ed25519PrivateKey, Ed25519PublicKey)
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
except ImportError:
    print("需要 cryptography：pip install cryptography")
    sys.exit(1)

API = "https://ade.pub"
ENC_MAGIC = "# ODP-ENCRYPTED-PRIVATE-KEY v1"


# ---------------------------------------------------------------- JCS（RFC 8785）
def _num(v):
    if isinstance(v, bool):
        raise ValueError
    if isinstance(v, int):
        return str(v)
    if isinstance(v, float):
        if not math.isfinite(v):
            raise ValueError
        if v == 0:
            return "0"
        if v.is_integer() and abs(v) < 1e15:
            return str(int(v))
        return repr(v)
    raise ValueError


def _jcs(v):
    if v is None:
        return "null"
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, (int, float)):
        return _num(v)
    if isinstance(v, str):
        return json.dumps(v, ensure_ascii=False)
    if isinstance(v, list):
        return "[" + ",".join(_jcs(x) for x in v) + "]"
    if isinstance(v, dict):
        items = sorted(v.items(), key=lambda kv: kv[0].encode("utf-16-be"))
        return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + _jcs(val)
                              for k, val in items) + "}"
    return json.dumps(v, ensure_ascii=False)


def canon(obj) -> bytes:
    return _jcs(obj).encode("utf-8")


# ---------------------------------------------------------------- 自证闸（§12.1 向量）
# 为什么 Python 版也要带一份向量：它是**审计基准**，而"基准"最危险的失效方式不是算错，
# 是环境变了（cryptography 版本、平台、编码习惯）之后继续算错而无人知。
# Node 版早就带了这个闸，此前 Python 版没有——而两版文件头都写着"两版都由内嵌向量自证"。
# 那句话之前是不成立的，现在才成立。（2026-10-17 工具闸门实测抓到。）
VEC_OBJ = {
    "odp_version": "1.0", "id": "urn:odp:example.org:2026:notice-vector", "type": "notice",
    "lang": "zh-CN", "title": "ODP §12 测试向量",
    "summary": "含非 ASCII、浮点、null、布尔、嵌套 claims 的最小记录。",
    "claims": [
        {"text": "向量断言：中文与 é，数字 0.65 / 1 / -0.0", "kind": "observation",
         "confidence": 0.65, "falsifiable_by": "任何与本向量不一致的实现输出"},
        {"text": "第二条：布尔与空值", "kind": "value", "confidence": 1}],
    "tags": ["test-vector", "odp"], "audience": "both", "contact": "mailto:vector@example.org",
    "source_uri": "https://example.org/odp/vector.ndjson",
    "published_at": "2026-10-16T00:00:00Z", "updated_at": "2026-10-16T00:00:00Z",
    "valid_until": None, "supersedes": None, "responds_to": [],
}
VEC_SHA256 = "432a46086194b07c1f1da3d4df1d34bafc4e2fcbfa7b35d83fec4b72301ae989"
VEC_LEN = 726
VEC_PUB = "uUPC5syLwxvwpSKGHe+78i1B+rsnSvmLfyhocDbH2aw="
VEC_SIG = "qOX/VL8ICOUHdXjYl1mVgtNo4yt9v4FPg8vZeJ4fP1t11w5oocF+AQHlzV+C4yBz829sKcEVIJBUuWaL9jaNCw=="


def selftest(quiet=False):
    """四道断言：规范化字节 → 哈希与长度 → 向量验签 → 篡改必失败。
    任何一条不过，签名类动作一律拒绝执行。"""
    fails = []
    c = canon(VEC_OBJ)
    if len(c) != VEC_LEN:
        fails.append(f"规范化字节长度 {len(c)} ≠ §12.1 向量的 {VEC_LEN}")
    if hashlib.sha256(c).hexdigest() != VEC_SHA256:
        fails.append("规范化字节的 SHA-256 与 §12.1 向量不一致")
    try:
        Ed25519PublicKey.from_public_bytes(base64.b64decode(VEC_PUB)).verify(
            base64.b64decode(VEC_SIG), c)
    except Exception as e:
        fails.append(f"向量验签失败：{e}")
    tampered = bytearray(c)
    tampered[0] ^= 0x01
    try:
        Ed25519PublicKey.from_public_bytes(base64.b64decode(VEC_PUB)).verify(
            base64.b64decode(VEC_SIG), bytes(tampered))
        fails.append("篡改后竟然验签通过——验签实现不可信")
    except Exception:
        pass
    if fails:
        print("SELFTEST FAILED")
        for f in fails:
            print("  ✗", f)
        print("→ 拒绝执行任何签名动作。跨实现字节级契约（§6.4-16）：一个不互通的实现"
              "比没有实现更危险。请核对 cryptography 版本与运行环境。")
        return False
    if not quiet:
        print(f"SELFTEST ok：规范化 {len(c)}B / 哈希一致 / 向量验签通过 / 篡改必失败")
    return True


def gate():
    if not selftest(quiet=True):
        selftest()
        sys.exit(1)


# -------------------------------------------------------------------- 基础工具
def _chmod_owner_only(path):
    try:
        os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
    except OSError:
        pass


def _load_priv(path):
    with open(path, "rb") as f:
        raw = f.read()
    # 坑 3：Windows 编辑器给 PEM 加了 UTF-8 BOM 时，load_pem_private_key 报的是
    # "Could not deserialize key data"——和"钥匙损坏/口令错"同一句错，极易误判。
    if raw.startswith(b"\xef\xbb\xbf"):
        raw = raw[3:]
    return ser.load_pem_private_key(raw, password=None)


def normalize_pubkey(s):
    """base64(raw32) / PEM 公钥 / PEM 私钥 → base64(raw32)。认不出返回 None。
    与索引侧同名函数同一约定（宿主文档三种公钥写法都收，§6.4）。"""
    t = (s or "").strip()
    if not t:
        return None
    if t.startswith("-----BEGIN"):
        b = t.encode()
        k = None
        try:
            k = ser.load_pem_public_key(b)
        except Exception:
            try:
                k = ser.load_pem_private_key(b, password=None)
            except Exception:
                return None
        k = k.public_key() if hasattr(k, "public_key") else k
        try:
            return base64.b64encode(k.public_bytes(ser.Encoding.Raw,
                                                   ser.PublicFormat.Raw)).decode()
        except Exception:
            return None
    try:
        return t if len(base64.b64decode(t, validate=True)) == 32 else None
    except Exception:
        return None


def _pub_raw_b64(key) -> str:
    return base64.b64encode(key.public_key().public_bytes(
        ser.Encoding.Raw, ser.PublicFormat.Raw)).decode()


def fingerprint(pub_b64: str) -> str:
    """公钥指纹：sha256(raw32) 前 16 位。用于与索引回执对账。"""
    return hashlib.sha256(base64.b64decode(pub_b64)).hexdigest()[:16]


def _get(url):
    """所有出网请求走同一条路（重试 + 逃生舱在 _http 里）。
    以前 check/verify/preflight 各走一个裸 urlopen：publish 稳、其它命令脆，
    而用户看不出区别，只会觉得"这工具时好时坏"。"""
    st, _, body = _http(url)
    if st >= 400:
        raise RuntimeError(f"HTTP {st}: {body[:200]}")
    return json.loads(body)


# ---------------------------------------------------------------- 加密备份
def encrypt_pem(pem_bytes: bytes, passphrase: str, pub_b64: str) -> str:
    salt, nonce = os.urandom(16), os.urandom(12)
    key = Scrypt(salt=salt, length=32, n=2 ** 15, r=8, p=1).derive(
        passphrase.encode("utf-8"))
    ct = AESGCM(key).encrypt(nonce, pem_bytes, None)
    return "\n".join([
        ENC_MAGIC,
        "kdf: scrypt;n=32768;r=8;p=1;dklen=32",
        "salt: " + base64.b64encode(salt).decode(),
        "nonce: " + base64.b64encode(nonce).decode(),
        "pubkey: " + pub_b64,
        "fingerprint: " + fingerprint(pub_b64),
        "ciphertext: " + base64.b64encode(ct).decode(),
        "",
    ])


def decrypt_file(path: str, passphrase: str) -> bytes:
    meta = {}
    for line in open(path, encoding="utf-8-sig"):
        line = line.strip()
        if line.startswith("#") or not line or ":" not in line:
            continue
        k, _, v = line.partition(":")
        meta[k.strip()] = v.strip()
    if meta.get("kdf", "").lower() not in ("scrypt;n=32768;r=8;p=1;dklen=32",):
        raise ValueError("unsupported_kdf")
    key = Scrypt(salt=base64.b64decode(meta["salt"]), length=32,
                 n=2 ** 15, r=8, p=1).derive(passphrase.encode("utf-8"))
    return AESGCM(key).decrypt(base64.b64decode(meta["nonce"]),
                               base64.b64decode(meta["ciphertext"]), None)


RECOVERY_NOTE = """ODP anchor 私钥恢复说明（请打印或另存一份，与密钥分开存放）
================================================================
这份文件说明"如何恢复对某个托管源的写入权"。私钥一旦丢失，索引不托管、
不重置、也无法代为恢复——所以本文件与加密副本就是你的全部备份。

1) 需要备份的三件套
   a. anchor.pem          私钥原文（只在本地，永不上网）
   b. anchor.enc          口令加密副本（放异地：云盘/U盘/邮件附件均可）
   c. 本文件 + 口令        口令**不要**与 anchor.enc 存放在同一处

2) 对账用的两个值
   公钥 (pubkey)：登记在索引的宿主文档里，公开可见，不是秘密。
   指纹 (fingerprint)：把这里的值抄下来；任何时候运行
       python odp-local-sign.py check <handle>
   若显示 MATCH，说明你手上的钥匙确实能接管那个托管源。

3) 恢复流程（本机密钥丢失、只剩加密副本时）
   python odp-local-sign.py decrypt anchor.enc restored.pem "你的口令"
   python odp-local-sign.py check <handle>          # 必须显示 MATCH
   之后即可继续对该 handle 签名投递/关闭/迁移。

4) 如果口令也丢了
   anchor.enc 无法破解（scrypt + AES-GCM）。此时该 handle 永久不可写，
   历史声明与其签名证明依然有效。正确做法：新建 handle，并在新声明中用
   supersedes / responds_to 指向旧 id 自述换钥匙——连续性降级为"可核对
   但不可证明"，这是诚实的边界，不要试图让索引代持钥匙。
"""


def _print_backup(pem, pub_b64, priv_path, enc_path, note_path, fp):
    print(json.dumps({
        "priv_pem": priv_path,
        "encrypted_copy": enc_path,
        "recovery_note": note_path,
        "pubkey": pub_b64,
        "pubkey_fingerprint": fp,
        "checklist": [
            "把 " + enc_path + " 传到异地（云盘/U盘/邮箱附件）",
            "口令单独存放，不要与加密副本同地",
            "记录 fingerprint=" + fp + "，之后用 `check <handle>` 对账",
            "确认 " + priv_path + " 权限为 600（本工具已尝试设置）",
            "在索引创建回执中比对 pubkey_fingerprint 是否与此一致",
        ],
    }, ensure_ascii=False, indent=1))


# ------------------------------------------------------------------ 子命令
def genkey(path, passphrase=None):
    gate()                    # 铸钥同样依赖这套密码学栈，先自证再生成
    key = Ed25519PrivateKey.generate()
    pem = key.private_bytes(ser.Encoding.PEM, ser.PrivateFormat.PKCS8,
                            ser.NoEncryption())
    with open(path, "wb") as f:
        f.write(pem)
    _chmod_owner_only(path)
    pub = _pub_raw_b64(key)
    fp = fingerprint(pub)
    pp = passphrase or os.environ.get("ODP_PASSPHRASE") or ""
    enc_path, note_path = path + ".enc", os.path.join(
        os.path.dirname(os.path.abspath(path)), "RECOVERY.txt")
    if pp:
        with open(enc_path, "w", encoding="utf-8") as f:
            f.write(encrypt_pem(pem, pp, pub))
        _chmod_owner_only(enc_path)
    else:
        print("⚠️ 未提供口令（第二参数或 ODP_PASSPHRASE）：跳过加密副本，"
              "请随后补跑 `encrypt`", file=sys.stderr)
    with open(note_path, "w", encoding="utf-8") as f:
        f.write(RECOVERY_NOTE)
    print(f"✅ 私钥：{path}（600）｜指纹：{fp}", file=sys.stderr)
    _print_backup(pem, pub, path, enc_path if pp else "(未生成)", note_path, fp)


def adopt(src: str, dst: str = "anchor.pem"):
    """接受声明者交付的私钥（Agent 侧专用）：落位为工作钥并打印指纹供核对。

    铁律（2026-10-16 铸钥权裁定）：钥匙只由声明者本人生成——
    Agent 侧只有 adopt（接受交付），没有 genkey 的用武之地。
    交付即全额授权：发布、修订、关闭、§17 迁移。收回授权 = §17.4 轮换。"""
    import os as _os
    pem = open(src, encoding="utf-8-sig").read()
    if "BEGIN" not in pem:
        sys.exit("ABORT：收到的不是 PEM 私钥（应含 BEGIN PRIVATE KEY）——"
                 "请声明者本人重新提供（钥匙只能由其生成，见 /howto §01）")
    with open(dst, "w", encoding="utf-8") as f:
        f.write(pem if pem.endswith("\n") else pem + "\n")
    _chmod_owner_only(dst)
    fp = fingerprint(_pub_raw_b64(_load_priv(dst)))
    print("ADOPT-OK", dst)
    print("fingerprint=" + fp)
    print("已按声明者交付落位。请把上面的指纹回给声明者核对（应与其本地 fingerprint 一致）。")
    print("提醒：交付即全额授权；本工具不生成钥匙（genkey 仅声明者本人可用）。")


def pubkey(path):
    print(_pub_raw_b64(_load_priv(path)))


def show_fp(path):
    raw = open(path, "rb").read()
    if b"BEGIN" in raw:
        print(fingerprint(_pub_raw_b64(ser.load_pem_private_key(raw, password=None))))
    else:
        print(fingerprint(raw.decode().strip()))


def encrypt(priv_path, out_path, passphrase=None):
    pp = passphrase or os.environ.get("ODP_PASSPHRASE") or ""
    if not pp:
        print("需要口令（第三参数或 ODP_PASSPHRASE）", file=sys.stderr)
        sys.exit(2)
    key = _load_priv(priv_path)
    pub = _pub_raw_b64(key)
    pem = key.private_bytes(ser.Encoding.PEM, ser.PrivateFormat.PKCS8,
                            ser.NoEncryption())
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(encrypt_pem(pem, pp, pub))
    _chmod_owner_only(out_path)
    print(f"✅ 加密副本已生成：{out_path}｜指纹：{fingerprint(pub)}")


def decrypt(enc_path, out_path, passphrase=None):
    pp = passphrase or os.environ.get("ODP_PASSPHRASE") or ""
    try:
        pem = decrypt_file(enc_path, pp)
        key = ser.load_pem_private_key(pem, password=None)      # 校验可解
    except Exception:
        # 不区分"口令错"与"文件损坏"：两者都不给可猜测的差异化信息
        print("解密失败：口令不正确，或加密副本已损坏（未写出任何文件）",
              file=sys.stderr)
        sys.exit(1)
    with open(out_path, "wb") as f:
        f.write(pem)
    _chmod_owner_only(out_path)
    print(f"✅ 已恢复私钥：{out_path}｜指纹：{fingerprint(_pub_raw_b64(key))}")


def check(handle, base=API):
    """对账：本地默认私钥的公钥 == 索引上该 handle 登记的公钥。"""
    priv = os.environ.get("ODP_ANCHOR", "anchor.pem")
    if not os.path.exists(priv):
        print(f"找不到本地私钥 {priv}（可用环境变量 ODP_ANCHOR 指定）")
        sys.exit(2)
    loc = _pub_raw_b64(_load_priv(priv))
    try:
        doc = _get(f"{base}/@{handle}/odp-host.json")
    except Exception as e:
        print(f"取宿主文档失败：{e}")
        sys.exit(1)
    registered = {k.get("key_id"): k.get("value") for k in doc.get("public_keys") or []}
    loc_fp = fingerprint(loc)
    for kid, val in registered.items():
        if val == loc:
            print(f"MATCH  handle={handle} key_id={kid} fingerprint={loc_fp}")
            return
    print(f"MISMATCH handle={handle} 本地指纹={loc_fp} "
          f"登记指纹={{ {', '.join(fingerprint(v) for v in registered.values() if v)} }}")
    print("→ 本地这把钥匙无法接管该托管源：确认拿对了文件，或用对的私钥重跑")
    sys.exit(3)


def sign(priv_path, key_id, src, dst):
    gate()                    # 任何签名动作前先过自证闸（与 Node 版同一纪律）
    key = _load_priv(priv_path)
    n = 0
    with open(src, encoding="utf-8-sig") as fi, open(dst, "w", encoding="utf-8") as fo:
        for line in fi:
            line = line.strip()
            if line:
                fo.write(json.dumps(_sign_one(json.loads(line), key, key_id),
                                    ensure_ascii=False) + "\n")
                n += 1
    print(f"已签名 {n} 条 -> {dst}")


def _sign_one(obj, key, key_id):
    body = dict(obj)
    body.pop("signature", None)
    body["signature"] = {"alg": "ed25519", "key_id": key_id,
                         "value": base64.b64encode(key.sign(canon(body))).decode()}
    return body


def fetch_keys(key_id):
    doc = _get(key_id.split("#")[0])
    return {k["key_id"]: k["value"] for k in doc.get("public_keys") or []}


def verify(key_id, src):
    doc_url, _, local = key_id.partition("#")
    try:
        keys = fetch_keys(key_id)
    except Exception as e:
        print(f"取宿主文档失败：{e}\n"
              f"→ 若这是新建托管源，属正常顺序问题：**必须先 POST /api/v1/hosted 登记公钥**，"
              f"再本地 verify（宿主文档还不存在时无法取到公钥）。")
        return
    if local not in keys:
        print(f"公钥未在 {doc_url} 登记（key_id={local}）。"
              f"\n→ 常见原因：先签名后创建托管源。正确顺序：genkey → 创建 handle（登记公钥）"
              f"→ sign → preflight → 投递。")
        return
    pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(keys[local]))
    ok = bad = 0
    for line in open(src, encoding="utf-8-sig"):
        line = line.strip()
        if not line:
            continue
        obj = json.loads(line)
        sig = obj.pop("signature", None)
        try:
            pub.verify(base64.b64decode(sig["value"]), canon(obj))
            ok += 1
        except Exception:
            bad += 1
            print("BAD:", obj.get("id"))
    print(f"VERIFY ok={ok} bad={bad}")


def preflight(src, handle=None, base=None):
    base = (base or API).rstrip("/")
    for line in open(src, encoding="utf-8-sig"):
        line = line.strip()
        if not line:
            continue
        body = {"declaration": json.loads(line)}
        if handle:
            body["handle"] = handle
        # 走 _http：与 publish 同一套重试/逃生舱，不再"一个命令稳、另一个命令脆"
        st, _, txt = _http(base + "/api/v1/preflight",
                           data=json.dumps(body, ensure_ascii=False),
                           ct="application/json", method="POST")
        try:
            print(json.dumps(json.loads(txt), ensure_ascii=False, indent=1))
        except Exception:
            print("HTTP", st, txt[:300])


_RETRYABLE = ("ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "EHOSTUNREACH",
              "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EOF occurred", "Connection aborted",
              "timed out", "Bad file descriptor", "Remote end closed", "socket hang up",
              # Python 的 urllib 报的是 Errno 文本而不是 ECONN* 码——不加这几条，
              # "连不上就重试"只对一半实现成立（实测：127.0.0.1:9 直接跳到 curl）
              "Connection refused", "Connection reset by peer", "Errno 111", "Errno 104",
              "getaddrinfo failed", "Name or service not known", "Unable to connect")


def _curl(path, data=None, ct="application/json", method=None):
    """逃生舱：Node 版实测到"TLS POST 被中间设备复位、同一时刻 curl 能通"，
    所以两版共用同一条退路（ODP_TRANSPORT=curl 可直接走这里）。
    用 -i 把响应头并到 stdout 前面解析，不留任何临时文件。"""
    import re
    import subprocess
    args = ["curl", "-sS", "-i", "--max-time", "60",
            "-X", method or ("POST" if data is not None else "GET"),
            "-H", f"Content-Type: {ct}", "-H", "User-Agent: odp-local-sign/1.0"]
    # 有 body 必须显式交给 curl：只给 -X POST 而不带 --data-binary，发出去的是
    # **空请求体**，索引回 bad_json——工具闸门抓到的真实缺陷（两版同时中招）。
    if data is not None:
        args += ["--data-binary", "@-"]
    args += ["-w", "\n__ODP_HTTP_CODE__%{http_code}",
            path if path.startswith(("http://", "https://")) else API + path]
    body = data.encode() if isinstance(data, str) else (data or b"")
    r = subprocess.run(args, input=body, capture_output=True)
    out = r.stdout.decode("utf-8", "replace")
    m = re.search(r"\n__ODP_HTTP_CODE__(\d{3})\s*$", out)
    if not m:
        raise RuntimeError(f"curl 未返回状态码（{r.stderr.decode('utf-8', 'replace')[:160]}）"
                           "——未投递任何记录，可安全重跑")
    out = out[:m.start()]
    status = int(m.group(1))
    if status == 0:
        # curl 在"根本没连上"时把 http_code 写成 0。把 0 当合法响应码往下走，
        # publish 会报成"预检响应不可解析"，把真实原因（连不上）盖掉——实测踩过。
        raise RuntimeError("curl 连接失败（HTTP 000）："
                           + r.stderr.decode("utf-8", "replace")[:160])
    head, sep, rest = out.partition("\r\n\r\n")
    if not sep:
        head, sep, rest = out.partition("\n\n")
    headers = {}
    for line in head.splitlines():
        if ":" in line and not line.upper().startswith("HTTP/"):
            k, _, v = line.partition(":")
            headers[k.strip()] = v.strip()
    return status, headers, rest


def _http(path, data=None, ct="application/json", method=None):
    """带响应头的统一请求（publish 需要读 X-ODP-Canonical-Hash）。
    path 可以是相对 API 的路径，也可以是完整 URL（check 会带自定义 base）。
    只对"确定没产生副作用"的失败重试（连不上 / 被复位 / 超时），指数退避 3 次；
    拿到任何 HTTP 响应就绝不重试。托管投递按 id upsert 且逐字节幂等，所以重跑安全。"""
    url = path if path.startswith(("http://", "https://")) else API + path
    forced = os.environ.get("ODP_TRANSPORT", "").lower()
    if forced == "curl":
        return _curl(url, data, ct, method)
    last = None
    for attempt in range(3):
        try:
            req = urllib.request.Request(url,
                                         data=data.encode() if isinstance(data, str) else data,
                                         headers={"Content-Type": ct},
                                         method=method or ("POST" if data is not None else "GET"))
            with urllib.request.urlopen(req, timeout=40) as r:
                return r.status, dict(r.headers), r.read().decode("utf-8", "replace")
        except urllib.error.HTTPError as e:      # 有响应 = 服务端已处理，绝不重试
            return e.code, dict(e.headers), e.read().decode("utf-8", "replace")
        except Exception as e:
            last = e
            if not any(k.lower() in str(e).lower() for k in _RETRYABLE):
                break
            if attempt < 2:
                wait = 0.5 * (2 ** attempt)
                print(f"RETRY      {url} —— {type(e).__name__}: {str(e)[:80]}，"
                      f"{wait:.1f}s 后第 {attempt + 2} 次尝试（此前零投递，重跑安全）")
                time.sleep(wait)
    msg = f"网络请求失败（{type(last).__name__}: {str(last)[:120]}）"
    if forced == "node":
        sys.exit(f"ABORT：{msg}——未投递任何记录，可安全重跑：{url}")
    print(f"FALLBACK   {msg}，改走系统 curl")
    try:
        return _curl(url, data, ct, method)
    except Exception as e2:
        sys.exit(f"ABORT：{msg}；curl 也失败（{str(e2)[:120]}）"
                 f"——未投递任何记录，可安全重跑：{url}")


def _host_keys(base, handle):
    """取宿主文档的公钥册，返回 (keys, 失败原因)。
    刻意**不吞传输错误**：网络失败要由 _http 抛出"未投递任何记录，可安全重跑"，
    而不是被当成"册子里没有这把钥匙"——后者会把人引向完全错误的方向
    （本轮改动一度引入这个缺陷，被部署前工具闸抓到）。"""
    st, _, body = _http(f"{base}/@{handle}/odp-host.json")
    if st == 404:
        return None, "该 handle 没有宿主文档（不存在或已关闭）"
    if st != 200:
        return None, f"取宿主文档失败（HTTP {st}）：{body[:120]}"
    try:
        return (json.loads(body).get("public_keys") or []), None
    except Exception as e:
        return None, f"宿主文档不是合法 JSON：{e}"


def _pick_key(keys, pub_b64, need="publish"):
    """按**本地公钥**在宿主文档里找它对应的那条登记。
    有了委托子钥之后，一个 handle 可能同时有多把"活着的钥"（anchor + 若干子钥），
    沿用旧逻辑"取第一条未退役的"会把子钥的签名标成 anchor 的 key_id ——
    索引拿 anchor 公钥一验就是 bad_sig，而且报错看起来像"你用了错的钥匙"。
    返回 (entry_or_None, 中止原因_or_None)。"""
    hit = None
    for k in keys:
        if k.get("value") == pub_b64:
            hit = k
            break
    if hit is None:
        return None, ("这把钥匙不在该 handle 的登记册里——既不是当前 anchor，"
                      "也不是任何一把已授权子钥")
    if hit.get("retired_at"):
        return None, f"key_id={hit.get('key_id')} 已于 {hit['retired_at']} 退役，不能再写入"
    if hit.get("delegated_by"):
        stt = hit.get("delegate_status") or "active"
        if stt != "active":
            return None, (f"子钥 key_id={hit.get('key_id')} 状态为 {stt}"
                          "（吊销/到期只挡新写入，不影响它已发布的声明）")
        if need not in (hit.get("granted") or []):
            return None, (f"子钥 key_id={hit.get('key_id')} 未被授予 {need}，"
                          f"只有 {hit.get('granted')}")
    return hit, None


def keys_cmd(handle, op, anchor="anchor.pem", delegate=None, key_id=None,
             granted="publish,withdraw", days=365, base=None, dry=False):
    """§6.4-20 委托子钥：用 anchor 签发（grant）或吊销（revoke）一把可回收的工作钥。

    为什么要它：交钥匙给 Agent 过去等于"全额授权"——它能发布，也能换根、关闭、迁移。
    子钥把最坏后果钉死在"乱发/乱撤声明"这一级，而且一次 revoke 就止血，不动身份根。
    两把私钥都只在本地签名，**永不上传**；上传的永远是公钥与已签好的凭证。
    """
    base = (base or API).rstrip("/")
    gate()
    if op not in ("grant", "revoke"):
        sys.exit("用法：keys <handle> grant --anchor a.pem --delegate sub.pem "
                 "[--key-id publish-2026-10-17] [--granted publish,withdraw] [--days 365]"
                 "     keys <handle> revoke --anchor a.pem --key-id publish-2026-10-17")
    akey = _load_priv(anchor)
    apub = _pub_raw_b64(akey)
    keys, kerr = _host_keys(base, handle)
    if kerr:
        sys.exit(f"ABORT：{kerr}（未产生任何密钥变更）")
    anchor_entry = next((k for k in keys if k.get("value") == apub), None)
    if not anchor_entry:
        sys.exit(f"ABORT：{anchor} 不是 @{handle} 的当前 anchor（也不在退役/子钥册里）。"
                 "管理子钥只能由 anchor 直签。")
    akid = anchor_entry.get("key_id") or "anchor"
    aurl = f"{base}/@{handle}/odp-host.json#{akid}"
    ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    if op == "grant":
        if not delegate:
            sys.exit("ABORT：grant 需要 --delegate <子钥.pem 或 base64 公钥>")
        if os.path.exists(delegate):
            dpub = _pub_raw_b64(_load_priv(delegate))
        else:
            dpub = normalize_pubkey(delegate) or delegate
        if dpub == apub:
            sys.exit("ABORT：子钥不能与 anchor 是同一把钥匙")
        kid = key_id or f"publish-{time.strftime('%Y-%m-%d', time.gmtime())}"
        perms = [x.strip() for x in (granted or "").split(",") if x.strip()]
        if not perms:
            sys.exit("ABORT：--granted 不能为空")
        blocked = [x for x in perms if x in ("manage_keys", "close", "migrate", "rotate_anchor")]
        if blocked:
            sys.exit(f"ABORT：这些权限永不授予子钥：{blocked}（只能 anchor 直签）")
        grant = {"odp_version": "1.0", "type": "odp-key-delegation", "handle": handle,
                 "ts": ts, "anchor_key_id": akid,
                 "delegate": {"key_id": kid, "alg": "ed25519", "value": dpub},
                 "granted": perms, "not_before": ts,
                 "not_after": time.strftime("%Y-%m-%dT%H:%M:%SZ",
                                            time.gmtime(time.time() + 86400 * days))}
        grant["signature"] = {"alg": "ed25519", "key_id": aurl, "value": base64.b64encode(
            akey.sign(canon({k: v for k, v in grant.items() if k != "signature"}))
        ).decode()}
        payload = grant
    else:
        if not key_id:
            sys.exit("ABORT：revoke 需要 --key-id <要吊销的子钥 key_id>")
        payload = {"key_id": key_id}
        grant = None
    env = {"operation": op, "handle": handle, "ts": ts}
    env["delegation" if op == "grant" else "revoke"] = payload
    msg = canon({"action": f"keys:{op}", "handle": handle, "ts": ts, "payload": payload})
    env["anchor_signature"] = {"alg": "ed25519", "key_id": aurl,
                               "value": base64.b64encode(akey.sign(msg)).decode()}
    print(f"ANCHOR   {anchor}  key_id={akid}  指纹={fingerprint(apub)}")
    if grant:
        print(f"GRANT    key_id={key_id or payload['delegate']['key_id']}  "
              f"granted={','.join(perms)}  有效期至 {grant['not_after']}")
    print(f"SIGN     anchor 直签两层：凭证本体 + 管理动作（action/handle/ts/payload）")
    if dry:
        print(json.dumps(env, ensure_ascii=False, indent=1))
        print("DRY-RUN 完成：未提交。")
        return
    st, _, body = _http(f"{base}/api/v1/hosted/{urllib.parse.quote(handle)}/keys",
                        json.dumps(env, ensure_ascii=False))
    try:
        r = json.loads(body)
    except Exception:
        sys.exit(f"ABORT：响应不可解析（HTTP {st}）{body[:200]}")
    print(json.dumps(r, ensure_ascii=False, indent=1)[:1600])
    if st != 200:
        sys.exit(f"ABORT：索引拒绝了对 {handle} 的 {op}（见上方错误码）——未产生任何密钥变更")
    if op == "grant":
        print(f"NEXT     把子钥私钥交给 Agent：python3 {os.path.basename(__file__)} adopt "
              f"<子钥.pem> agent.pem —— 然后 Agent 用 --key agent.pem publish 即可，"
              f"anchor 不必在场")


def publish(handle, src, key="anchor.pem", base=None, dry=False):
    """S2+S4：一条命令完成 自动补齐可推导字段 → 本地签名 → 预检 → 投递 → 四行回执。

    自动补齐的字段（全部打印，绝不静默——改写作者要签的字节必须知情）：
      source_uri   = <base>/@<handle>/declarations.ndjson   （由 handle 推出）
      human_uri    = <base>/@<handle>                       （规范 §15-8 判据④）
      published_at = 当下 UTC，秒级、Z 结尾                  （2026-10-17 采用者反馈）
      updated_at   = 同上
    前两个是"只有按协议规则才知道该填什么"的可推导字段；时间是另一类：
    "我此刻发布"在发布那一刻就是确定值，过去却要求人手写 RFC 3339 才肯干活——
    那是把工具的分类负担转给声明者。显式写了值就以你写的为准，工具不覆盖。
    key_id 同样由 <base>/@<handle>/odp-host.json 推出（取未退役的那把钥），不再要求人手工拼全 URL。
    预检不过 → 原样打印 errors 并以 2 退出，**一条都不投递**；这是 append-only 下
    唯一零代价的纠错窗口，不做"我帮你强推"的选项。
    """
    base = (base or API).rstrip("/")
    gate()                    # 任何签名动作前先过自证闸（与 Node 版同一纪律）
    # key_id 属于"可推导字段"，但必须推得对。旧逻辑是"取第一条未退役的登记钥"——
    # 在只有 anchor 时够用，引入委托子钥（§6.4-20）之后一个 handle 可能同时有
    # anchor + 若干活跃子钥，"取第一条"会把子钥签的标成 anchor 的 key_id，
    # 索引拿 anchor 公钥一验就 bad_sig，而报错长得像"你拿错了钥匙"。
    # 现在按**本地公钥反查登记册**，并顺带在本地就挡住退役/到期/吊销/权限不足的钥。
    priv = _load_priv(key)
    pub = _pub_raw_b64(priv)
    keys0, kerr = _host_keys(base, handle)
    if kerr:
        sys.exit(f"ABORT：无法为 @{handle} 选钥匙——{kerr}（未投递任何记录，可安全重跑）")
    recs = []
    # 坑 3（BOM）：Windows 编辑器 / PowerShell 常给文件加 UTF-8 BOM。
    # encoding="utf-8-sig" 会吞掉 BOM（没有 BOM 时也完全无害），
    # 否则第一条 json.loads 必炸——而报出来的错是"语法错误"，把人引向完全错误的方向。
    with open(src, encoding="utf-8-sig") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            d = json.loads(line)
            d.pop("signature", None)
            miss = [f for f in ("id", "type", "title", "summary") if not d.get(f)]
            if miss:
                sys.exit(f"ABORT：声明缺必填字段 {miss}（id/type/title/summary）")
            # 坑 2（自动填时间）：published_at 以前必填，等于要求人手写 RFC3339 才能发布。
            # source_uri/human_uri 是"只有声明者知道该怎么填"的可推导字段；时间不是——
            # "我此刻发布"在发布那一刻就是确定值。按当下 UTC 补，秒级、带 Z，
            # 与 Node 版同一格式；并且照旧打印，让它进入签名覆盖字节时是**知情**的。
            now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
            for f, v in (("source_uri", f"{base}/@{handle}/declarations.ndjson"),
                         ("human_uri", f"{base}/@{handle}"),
                         ("published_at", now), ("updated_at", now)):
                if not d.get(f):
                    d[f] = v
                    print(f"AUTO  {f} = {v}   （原本缺失，按 §5.3/§15-8 推导或按当下 UTC 补齐并写入签名覆盖字节）")
            recs.append(d)
    if not recs:
        sys.exit(f"ABORT：{src} 里没有声明")

    # 本次要用的权限由**要投递什么**决定：全带 withdrawn 的记录要 withdraw，
    # 其余（新发布/修订）要 publish。索引侧按同一规则判定（§6.4-20），
    # 这里先在本地挡一道——省得跑完签名才发现子钥没这个权限。
    need = "withdraw" if all(d.get("withdrawn") for d in recs) else "publish"
    hit, why = _pick_key(keys0, pub, need=need)
    if why:
        reg = ", ".join(f"{k.get('key_id')}({'子钥' if k.get('delegated_by') else 'anchor'}"
                        f"{'/退役' if k.get('retired_at') else ''})"
                        for k in keys0 if k.get("value")) or "（读不到登记册）"
        sys.exit(f"ABORT：无法为 @{handle} 使用这把钥匙（本地指纹 {fingerprint(pub)}）——{why}\n"
                 f"        在册的钥匙：{reg}")
    kid = f"{base}/@{handle}/odp-host.json#{hit.get('key_id') or 'anchor'}"
    if hit.get("delegated_by"):
        print(f"KEYID    {hit.get('key_id')} 是 anchor（{hit['delegated_by']}）签发的委托子钥："
              f"granted={','.join(hit.get('granted') or [])}  本次需要 {need}")

    print(f"KEY   {key}  指纹={fingerprint(pub)}  key_id={kid}")
    for d in recs:
        msg = canon(d)
        d["signature"] = {"alg": "ed25519", "key_id": kid,
                          "value": base64.b64encode(priv.sign(msg)).decode()}
    print(f"SIGN  {len(recs)} 条已本地签名（Ed25519 直签 JCS 字节，无预散列）")

    for d in recs:
        st, _, body = _http(base + "/api/v1/preflight",
                            json.dumps({"declaration": d, "handle": handle}, ensure_ascii=False))
        try:
            j = json.loads(body)
        except Exception:
            sys.exit(f"ABORT：预检响应不可解析（HTTP {st}）{body[:200]}")
        if st != 200 or j.get("valid") is not True:
            print("PREFLIGHT FAILED", d.get("id"))
            print(json.dumps(j, ensure_ascii=False, indent=1)[:1600])
            sys.exit(2)
        print(f"PREFLIGHT ok {d['id']}  identity_if_published="
              f"{(j.get('preview') or {}).get('identity_if_published')}")
    if dry:
        print("DRY-RUN 完成：已签名并预检通过，未投递。")
        return

    payload = "\n".join(json.dumps(d, ensure_ascii=False) for d in recs) + "\n"
    st, _, body = _http(base + f"/api/v1/hosted/{urllib.parse.quote(handle)}/declarations",
                        payload, ct="application/x-ndjson")
    try:
        res = json.loads(body)
    except Exception:
        sys.exit(f"投递响应不可解析（HTTP {st}）：{body[:200]}")
    if st != 200 or res.get("accepted") != len(recs) or res.get("rejected"):
        print("SUBMIT PROBLEM", st, json.dumps(res, ensure_ascii=False)[:800])
        sys.exit(3)
    rmap = {x.get("id"): x for x in (res.get("results") or [])}
    for d in recs:                              # 四行回执＝§15-8 判据④的机读形式
        h = urllib.parse.quote(d["id"], safe="")
        st2, hd2, _ = _http(base + f"/raw/{h}")
        chash = hd2.get("X-ODP-Canonical-Hash") or hd2.get("x-odp-canonical-hash") or "?"
        state = (hd2.get("X-ODP-Status") or hd2.get("x-odp-status") or "").lower()
        wd = bool(rmap.get(d["id"], {}).get("withdrawn")) or state == "withdrawn"
        # 撤回不是"发布失败"，也不该长得像发布成功——首行必须说清发生了什么状态转换。
        print(f"{'WITHDRAWN' if wd else 'PUBLISHED'}  {d['id']}")
        print(f"HUMAN      {base}/@{handle}")
        print(f"CANONICAL  {base}/d/{d['id']}")
        print(f"BYTES      {base}/raw/{d['id']}  X-ODP-Canonical-Hash: {chash}  (HTTP {st2})"
              + ("  X-ODP-Status: withdrawn" if state == "withdrawn" else ""))
        if wd:
            print(f"STATE      withdrawn —— 内容按 append-only 保留为首见副本（§5.4），"
                  f"本 handle 清单里这条带 withdrawn:true；撤回是终态，要再说一次请发新 id")
    print(f"DONE       accepted={res.get('accepted')} rejected={res.get('rejected')}")


if __name__ == "__main__":
    a = sys.argv[1:]
    if not a:
        print(__doc__)
    elif a[0] == "genkey" and len(a) in (2, 3):
        genkey(a[1], a[2] if len(a) == 3 else None)
    elif a[0] == "adopt" and len(a) in (2, 3):
        adopt(a[1], a[2] if len(a) == 3 else "anchor.pem")
    elif a[0] == "pubkey" and len(a) == 2:
        pubkey(a[1])
    elif a[0] == "fingerprint" and len(a) == 2:
        show_fp(a[1])
    elif a[0] == "encrypt" and len(a) in (3, 4):
        encrypt(a[1], a[2], a[3] if len(a) == 4 else None)
    elif a[0] == "decrypt" and len(a) in (3, 4):
        decrypt(a[1], a[2], a[3] if len(a) == 4 else None)
    elif a[0] == "check" and len(a) in (2, 3):
        check(a[1], a[2] if len(a) == 3 else API)
    elif a[0] == "keys" and len(a) >= 3:
        # odp-local-sign.py keys <handle> grant  --anchor a.pem --delegate sub.pem
        #                                       [--key-id publish-2026-10-17]
        #                                       [--granted publish,withdraw] [--days 365]
        #                                       [--base URL] [--dry-run]
        # odp-local-sign.py keys <handle> revoke --anchor a.pem --key-id publish-2026-10-17
        fl = {}
        i = 3
        while i < len(a):
            if a[i].startswith("--"):
                k = a[i][2:]
                if k == "dry-run":
                    fl[k] = True
                else:
                    fl[k] = a[i + 1] if i + 1 < len(a) else ""
                    i += 1
            i += 1
        keys_cmd(a[1], a[2], anchor=fl.get("anchor") or "anchor.pem",
                 delegate=fl.get("delegate"), key_id=fl.get("key-id"),
                 granted=fl.get("granted") or "publish,withdraw",
                 days=int(fl.get("days") or 365), base=fl.get("base"),
                 dry=bool(fl.get("dry-run")))
    elif a[0] == "sign" and len(a) == 5:
        sign(a[1], a[2], a[3], a[4])
    elif a[0] == "verify" and len(a) == 3:
        verify(a[1], a[2])
    elif a[0] == "selftest":
        sys.exit(0 if selftest() else 1)
    elif a[0] == "preflight" and len(a) in (2, 3, 4):
        preflight(a[1], a[2] if len(a) >= 3 else None, a[3] if len(a) >= 4 else None)
    elif a[0] == "publish":
        # odp-local-sign.py publish --handle H --in decls.ndjson [--key anchor.pem]
        #                           [--base https://ade.pub] [--dry-run]
        fl = {}
        i = 1
        while i < len(a):
            if a[i].startswith("--"):
                k = a[i][2:]
                if k == "dry-run":
                    fl[k] = True
                else:
                    fl[k] = a[i + 1] if i + 1 < len(a) else ""
                    i += 1
                i += 1
            else:
                i += 1
        if not fl.get("handle") or not fl.get("in"):
            sys.exit("用法：publish --handle <handle> --in <decls.ndjson> "
                     "[--key anchor.pem] [--base https://ade.pub] [--dry-run]")
        publish(fl["handle"], fl["in"], fl.get("key") or "anchor.pem",
                fl.get("base"), bool(fl.get("dry-run")))
    else:
        print(__doc__)
