#!/usr/bin/env bash
# session-mail-verify — verify a keyed session's mail signature (CA-77).
#
#   session-mail-verify <mail-file> [--quiet] [--trust] [--require-blessing]
#
# Checks the X-Session-Signature header (SSHSIG over the canonical form of
# from/to/subject/date/message-id + body — parity contract with
# session-mail-send). The SSHSIG format embeds the signer's full public key,
# so the message is SELF-ATTRIBUTING: no registry lookup is needed to answer
# "which session signed this" — the sender's session-ID is derived as
# uuid = sha256(pubkey_wire_blob)[:16]. (A registry / allowed_signers file
# answers the separate trust question: WHOSE session that was.)
#
# Output: signer key, derived session-ID, crypto + principal verdicts.
# Exit (plain mode, unchanged): 0 verified · 1 BAD signature · 2 unsigned
# (no header) · 3 usage / arg parse / unreadable mailfile.
#
# --trust (SESH-152) runs the full three-leg trust test — the boot-mail
# ritual every keyed agent otherwise performed by hand (measured 2026-07-26:
# ~10 tool calls and 4 dead ends per boot, per agent):
#   1. signature-crypto + principal-binding (the checks above)
#   2. registry-cert: $SESSION_REGISTRY/certs/<sid>.cert exists — an EXACT
#      filename lookup keyed on the SIGNATURE-DERIVED sid, never on
#      X-Session-From (an unauthenticated header must not steer the trust
#      decision); the cert's session-id AND pubkey must match the SIGNER,
#      and its `agent:` must equal the From local-part (+tag stripped) —
#      anti-spoof, via the SAME reduction canonical() signs (one code path,
#      so the match compares exactly what leg 1 verified).
#   3. blessed-by: <cert>.blessed-by.<principal>.sig VERIFIES over the cert
#      bytes against the registry's pinned blessing-principal key —
#      verified, never stat'd.
#
# --trust exit contract — THREE OUTCOMES, ruled by super.session 2026-07-26
# 10:48 (deliberately NOT binary: most of the fleet is legitimately
# unblessed while the blessing ceremony is intermittent — CA-176 — so a
# binary contract would reject honest mail and teach agents to ignore the
# tool; "the bug is the BINARY, not the threshold"). Absent and invalid are
# never conflated, in either direction:
#
#   0  TRUST: FULL
#        all three legs verified; blessing present AND verifies.
#   1  TRUST: FAIL leg=<L> reason=<token>
#        a leg EVALUATED AND REJECTED. Includes a present-but-INVALID
#        bless.sig (ALWAYS rc 1, never rc 4 — no path from a forged
#        blessing to anything but failure) and an unparseable cert
#        (cert-invalid — attacker-influenceable input never buys softer
#        treatment than forgery). Tokens: leg 1 unsigned | bad-signature |
#        bad-principal; leg 2 no-cert | cert-invalid | cert-mismatch |
#        agent-mismatch; leg 3 bless-invalid | bless-absent (the latter
#        only under --require-blessing).
#   4  TRUST: UNBLESSED reason=bless-absent
#        legs 1–2 verified; blessing ABSENT — the common honest state
#        today. --require-blessing promotes this to rc 1 (policy belongs
#        to the caller: when the fleet is blessed, strictness becomes the
#        default by a one-line change, not a redesign).
#   5  TRUST: ERROR reason=registry-absent|allowed-signers-absent
#        the test COULD NOT BE RUN — fix the environment, not the mail.
#        Fail closed; there is no reduced mode. Plain mail-verify IS the
#        reduced mode, under its honest name.
#
# Plain-mode codes 0/1/2/3 are byte-compatible and untouched; rc 2 never
# occurs under --trust (unsigned is a leg-1 FAIL there); 6+ reserved.
# INVARIANT: every --trust run that parsed its arguments emits EXACTLY ONE
# "TRUST:" line, as the LAST line of stdout (even under --quiet); rc 3
# emits none — the absence of a TRUST: line is itself the signal.
# Registry root: $SESSION_REGISTRY if set (authoritative — a set-but-
# missing path is an ERROR, not a silent fallback to the default), else
# the claude-agents default; missing => TRUST: ERROR rc 5, never a stack
# trace.
# This is the CA-110 seam session-provenance's docstring reserves for mail
# identity; constants (namespace/principal) match session-provenance.
#
# Mirror note (SESH-152 do-002): this is the private, STANDALONE session.git
# copy (not a materialized exhibit of skillbox — SESH-101 Ruling B). The
# namespace/principal constants below are ENV-OVERRIDABLE with exhibit-safe
# defaults shipped in this file (SESH-101 M3 — parameterise, don't
# un-scrub — engineer.session's inventory §4/§6, endorsed by Mike via pip
# 11:22 / 11:38-11:42). The MECHANISM is decided (env, matching
# session-mail-send's existing SESSION_MAIL_SIG_NS pattern); the DEFAULT
# VALUE that should ship live is still open at the SESH-101 desk session,
# so it lands here as a one-line default swap per constant, never a
# re-port. See SESH-101 inventory.md §6.
exec python3 - "$@" <<'PYEOF'
import base64
import email
import hashlib
import os
import struct
import subprocess
import sys
import tempfile
import textwrap

BEGIN = "-----BEGIN SSH SIGNATURE-----"
END = "-----END SSH SIGNATURE-----"
HDRS = ["from", "to", "subject", "date", "message-id"]
SIGH = "X-Session-Signature"

# Scrub-sensitive namespace/principal constants — env-overridable, with
# exhibit-safe defaults shipped in this private mirror (SESH-101 M3;
# do-002). Never inline these at use sites, so the eventual desk decision
# (or a per-install env override) is a one-line change. SESSION_MAIL_SIG_NS
# matches the env var session-mail-send already uses for its own SIG_NS —
# one name, one code path, both scripts follow it together.
MAIL_NS_DEFAULT = os.environ.get("SESSION_MAIL_SIG_NS", "session-mail@example.com")
BLESS_NS = os.environ.get("SESSION_BLESS_NS", "session-bless@example.com")  # matches session-provenance
BLESS_PRINCIPAL = os.environ.get("SESSION_BLESS_PRINCIPAL", "alice@bless")
REGISTRY_DEFAULT = os.path.expanduser(
    "~/Code/github.com/mbailey/claude-agents/registry"
)

USAGE = (
    "usage: session-mail-verify <mail-file> [--quiet] [--trust]"
    " [--require-blessing]"
)

argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
    # Named help, not a traceback (SESH-152: '--help' used to fall through
    # to open('--help') and stack-trace). Exits via the usage bucket (3),
    # NOT 0: rc 0 is reserved for verified / TRUST: FULL, so no lazy
    # `if session-mail-verify --trust …` caller can mistake help for trust.
    print(USAGE)
    print("  --quiet             suppress per-leg diagnostic lines")
    print("  --trust             run the three-leg trust test (SESH-152):")
    print("                      rc 0 TRUST: FULL | 1 FAIL | 4 UNBLESSED | 5 ERROR")
    print("  --require-blessing  with --trust: promote UNBLESSED (rc 4) to FAIL (rc 1)")
    sys.exit(3)

FLAGS = ("--quiet", "--trust", "--require-blessing")
args = [a for a in argv if a not in FLAGS]
quiet = "--quiet" in argv
trust = "--trust" in argv
require_blessing = "--require-blessing" in argv
if len(args) != 1 or args[0].startswith("-"):
    print(USAGE, file=sys.stderr)
    sys.exit(3)
if require_blessing and not trust:
    print("session-mail-verify: --require-blessing requires --trust",
          file=sys.stderr)
    print(USAGE, file=sys.stderr)
    sys.exit(3)

def out(line):
    if not quiet:
        print(line)

def verdict(line, rc):
    # The ONLY writer of a "TRUST:" line. Printed even under --quiet — the
    # verdict is the machine-readable half of the contract: exactly one
    # TRUST: line, the last line of stdout, for every --trust run that
    # parsed its arguments (rc 3 emits none).
    print(line)
    sys.exit(rc)

def trust_fail(leg, reason):
    verdict(f"TRUST: FAIL leg={leg} reason={reason}", 1)

def sid_from_blob(blob: bytes) -> str:
    h = hashlib.sha256(blob).digest()[:16].hex()
    return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"

def parse_sshsig(armor: str):
    lines = [l.strip() for l in armor.strip().splitlines() if l.strip()]
    if lines[0] != BEGIN or lines[-1] != END:
        raise ValueError("not an SSHSIG armor")
    raw = base64.b64decode("".join(lines[1:-1]))
    if raw[:6] != b"SSHSIG":
        raise ValueError("bad SSHSIG magic")
    off = 10  # magic(6) + version uint32

    def take():
        nonlocal off
        (n,) = struct.unpack(">I", raw[off : off + 4])
        off += 4
        v = raw[off : off + n]
        off += n
        return v

    pubblob = take()
    namespace = take().decode()
    return pubblob, namespace

def keytype(blob: bytes) -> str:
    (n,) = struct.unpack(">I", blob[:4])
    return blob[4 : 4 + n].decode()

def local_part(v, strip_tag=False):
    # Transit-invariant address reduction (SKB-977 + CA-110 v2, design.md
    # §2): postfix qualifies bare From/To in transit (alice -> alice@<fqdn>)
    # but never rewrites the local-part, so addresses reduce to their
    # lowercased local-part — matching session-mail-send's canon_addr(),
    # which signs the same reduction. v2: take the innermost <...> content
    # when an angle-addr is present (else the whole field) BEFORE splitting
    # on '@', so a name-addr form (`Foreman <foreman.ca-104@host>`) reduces
    # to the local-part instead of display-name garbage. This side is
    # BEST-EFFORT at that step (unlike send's strict reject of G6 RFC
    # exotica): verify must attempt whatever the transport actually
    # delivered, so a malformed residual just flows through rather than
    # aborting. Byte-identical on the forms both sides accept is what makes
    # a postfix-delivered message verify the same as a filedropped one.
    #
    # ONE CODE PATH (SESH-152 N1): canonical() — what leg 1 signs and
    # verifies — and the leg-2 anti-spoof agent-match BOTH call this
    # helper, so the anti-spoof compares exactly the reduction leg 1
    # verified and the two cannot drift apart. strip_tag additionally
    # drops a +tag (routing, not identity) — used by the agent-match only;
    # the signature covers the tag intact, matching session-mail-send.
    v = " ".join(str(v or "").split())
    i = v.rfind("<")
    j = v.find(">", i + 1) if i != -1 else -1
    if i != -1 and j != -1:
        v = " ".join(v[i + 1 : j].split())
    v = v.split("@", 1)[0].lower()
    if strip_tag:
        v = v.split("+", 1)[0]
    return v

def canonical(msg) -> bytes:
    # Address reduction rationale lives on local_part() (shared with the
    # leg-2 anti-spoof — one code path, N1).
    parts = []
    for h in HDRS:
        v = msg.get(h, "") or ""
        v = " ".join(str(v).split())
        if h in ("from", "to"):
            v = local_part(v)
        parts.append(f"{h}: {v}\n")
    body = msg.get_payload()
    if isinstance(body, list):
        # SESH-890 D2 — NARROW multipart unwrap, and deliberately nothing wider.
        # A human on To/Cc/Bcc now gets multipart/alternative (the markdown as
        # the text part, a rendered HTML part beside it), and THE SEND SIDE
        # STILL SIGNS THE MARKDOWN: session-mail-send builds its canon from
        # $BODY *before* assembly, so a single-part and a multipart send of the
        # same body sign the same canonical string by construction. Verify
        # therefore only has to find that markdown again — hence an unwrap,
        # not a v1->v2 canonicalization bump (which :740-757 warns is a
        # two-sided drag), and hence old single-part mail keeps verifying: the
        # path below it is untouched.
        # NARROW ON PURPOSE: iff multipart/alternative AND the first part is
        # text/plain. This is not general multipart verification; every other
        # shape still refuses, so v1's guarantees stay checkable.
        # RAW get_payload(), NO decode=True. With CTE 8bit the raw string IS
        # the markdown, and staying undecoded matches the single-part path
        # above EXACTLY. decode=True would silently diverge the two the day
        # any other CTE appears — a signature break with no visible cause.
        # The HTML part goes unsigned on purpose: derived and reproducible
        # from the signed markdown, so a renderer upgrade is never a
        # signature break.
        # EDITOR'S NOTE: keep this function free of BLANK LINES.
        # test_session_mail_canon.sh extracts it live with
        # `sed -n '/^def canonical(msg)/,/^$/p'` — a blank line here silently
        # truncates the extracted reducer and that test stops testing.
        sub = body[0] if body else None
        if (
            msg.get_content_type() == "multipart/alternative"
            and sub is not None
            and sub.get_content_type() == "text/plain"
        ):
            body = sub.get_payload()
        if isinstance(body, list):
            # Still a list => the unwrap did not apply. Keep rc 3 (the contract
            # is unchanged) but say what this IS: a canonicalization LIMIT of
            # this tool, not a broken file. The old wording ("multipart not
            # supported") pointed readers at damage in a perfectly well-formed
            # message, because rc 3 is documented as *usage / unreadable
            # mailfile* — AC7's own observation.
            print(
                f"{msg.get_content_type()}: canonicalization v1 unwraps only "
                "multipart/alternative with a text/plain first part — the "
                "message is well-formed; the limit is this tool's",
                file=sys.stderr,
            )
            sys.exit(3)
    # SESH-653: mirror EXACTLY what the signer signs. session-mail-send builds
    # its canonical with bash `$(...)`, which strips ALL trailing newlines,
    # then `printf '%s\n'` appends exactly one. So whatever the body, the
    # signed text is rstrip("\n") + "\n" of the whole thing. An empty body
    # therefore signs as "...message-id: X\n" — no blank-line separator at all.
    # Before this fix every empty-body message (every MHS label mail:
    # [ROOT], [BOOT], [CHARTER]) verified BAD on both legs. Found by pip,
    # 2026-08-23, on the first real run of the read guard MHS 170 rules.
    #
    # CITATION REPAIR, SESH-890 do-006. This comment used to end "...while the
    # wire (`echo "$BODY"`) still carries a bare \n". That was true when it was
    # written and is FALSE NOW: SESH-674 emits the body with `printf '%s'`
    # (session-mail-send's single-part branch), which appends nothing, so the
    # wire carries exactly the body's own bytes and an empty body puts NOTHING
    # after the header blank line.
    #
    # THE CODE BELOW IS UNCHANGED AND STILL CORRECT — only the prose lied, and
    # that is the more dangerous half: a stale comment describing a BYTE-LEVEL
    # WIRE PROPERTY reads like settled knowledge, so the next person to reason
    # about canonicalization takes it as fact and never re-measures it. The
    # rstrip+1 on BOTH sides is what makes the two agree whether or not the
    # wire carries a final newline — which is why 674 could change the wire
    # without touching this function.
    #
    # WHY THE EMPTY-BODY BRANCH STILL EARNS ITS KEEP even though an empty body
    # can no longer be MINTED (measured 2026-09-02: `--body-file` on a 0-byte
    # file and a bodyless invocation BOTH refuse with rc=2, the SESH-674
    # loud-empty-refusal): messages minted BEFORE that refusal are on disk
    # forever and must keep verifying. Old mail is the reason, not new mail.
    text = "".join(parts) + "\n" + body.replace("\r\n", "\n")
    return (text.rstrip("\n") + "\n").encode()

def registry_root():
    # $SESSION_REGISTRY, when SET, is authoritative — a set-but-missing
    # path is an ERROR, never a silent fallback to the default (a fallback
    # would mask misconfiguration exactly at boot, the moment of least
    # context).
    envr = os.environ.get("SESSION_REGISTRY")
    cand = envr if envr else REGISTRY_DEFAULT
    if os.path.isdir(cand):
        return cand
    src = "$SESSION_REGISTRY" if envr else "default"
    print(
        f"registry not found ({src}): {cand} — set SESSION_REGISTRY "
        f"(default: {REGISTRY_DEFAULT})",
        file=sys.stderr,
    )
    verdict("TRUST: ERROR reason=registry-absent", 5)

def run_trust(sid, keyline, msg):
    # ---- leg 2: registry-cert — EXACT lookup by the SIGNATURE-DERIVED sid.
    # The cert filename IS sha256(signing key)[:16]: registering a cert
    # under a victim's sid without the victim's key needs a sha256
    # preimage, and presenting a sid you don't hold the key for already
    # failed leg 1. X-Session-From is never consulted here.
    reg = registry_root()
    cert_path = os.path.join(reg, "certs", f"{sid}.cert")
    if not os.path.isfile(cert_path):
        out(f"registry-cert     : ❌ no cert for session {sid}")
        trust_fail("2-registry", "no-cert")
    try:
        cert_bytes = open(cert_path, "rb").read()
        cert_lines = cert_bytes.decode().splitlines()  # strict decode:
        # garbage bytes are cert-invalid, not a traceback
        if not cert_lines or cert_lines[0].strip() != "session-birth-v1":
            raise ValueError("not a session-birth-v1 cert")
        fields = {}
        for line in cert_lines[1:]:
            if not line.strip():
                continue
            k, sep, v = line.partition(":")
            if not sep:
                raise ValueError(f"unparseable cert line {line!r}")
            fields[k.strip()] = v.strip()
        for req in ("session-id", "pubkey", "agent"):
            if not fields.get(req):
                raise ValueError(f"cert missing field {req!r}")
    except Exception as e:
        # FAIL CLOSED, never ERROR: the cert is attacker-influenceable
        # input — mangling it must not buy softer treatment than forging
        # it (SESH-152 design §3, engineer.session).
        out(f"registry-cert     : ❌ cert unparseable: {e}")
        trust_fail("2-registry", "cert-invalid")
    if fields["session-id"] != sid or fields["pubkey"] != keyline:
        out("registry-cert     : ❌ cert session-id/pubkey do not match the "
            "SIGNING key (tampered or wrong cert)")
        trust_fail("2-registry", "cert-mismatch")
    # Anti-spoof: cert agent vs From local-part, +tag stripped, through the
    # SAME local_part() reduction leg 1 verified (N1 — one code path).
    sender = local_part(msg.get("from", ""), strip_tag=True)
    agent = fields["agent"].lower()
    if agent != sender:
        out(f"registry-cert     : ❌ cert agent {agent!r} != From "
            f"local-part {sender!r} (spoof?)")
        trust_fail("2-registry", "agent-mismatch")
    out(f"registry-cert     : ✅ ok  {sid[:8]} → agent: {agent}")

    # ---- leg 3: bless — tri-state: verified / absent / invalid.
    bless = f"{cert_path}.blessed-by.{BLESS_PRINCIPAL}.sig"
    signers = os.path.join(reg, "allowed_signers")
    if not os.path.isfile(bless):
        # ABSENT is not INVALID. Lazy requirement: with no signature to
        # verify, allowed_signers isn't needed — legs 1–2 are
        # self-contained, so UNBLESSED is fully determined even when
        # allowed_signers is missing.
        out("bless-sig         : ⚠️  absent — unblessed (a normal state "
            "today; the blessing ceremony is intermittent, CA-176)")
        if require_blessing:
            # Promotion 4→1: same reason token so logs correlate across
            # policy; the verdict word carries the policy.
            trust_fail("3-bless", "bless-absent")
        verdict("TRUST: UNBLESSED reason=bless-absent", 4)
    if not os.path.isfile(signers):
        # A PRESENT bless.sig with no trust anchor: the test could not be
        # run — not bless-invalid (it didn't fail), not UNBLESSED (it
        # isn't absent).
        print(f"allowed_signers not found: {signers} — cannot verify a "
              "present bless.sig", file=sys.stderr)
        verdict("TRUST: ERROR reason=allowed-signers-absent", 5)
    pb = subprocess.run(
        ["ssh-keygen", "-Y", "verify", "-f", signers, "-I", BLESS_PRINCIPAL,
         "-n", BLESS_NS, "-s", bless],
        input=cert_bytes, capture_output=True,
    )
    # ssh-keygen -Y verify exits 255 on failure, NOT 1 — the only pass is
    # rc == 0 (proven live 2026-07-26: forged sig => "incorrect
    # signature", rc 255). Testing rc == 1 would pass forgeries.
    if pb.returncode != 0:
        out("bless-sig         : ❌ PRESENT but does NOT verify over the "
            "cert bytes — forgery-shaped (the stat-vs-verify distinction)")
        trust_fail("3-bless", "bless-invalid")
    out(f"bless-sig         : ✅ {BLESS_PRINCIPAL} verifies over cert bytes")
    verdict("TRUST: FULL", 0)

try:
    raw = open(args[0]).read()
except OSError as e:
    # Named error, not a traceback (raw-traceback ENOENT found live by pip
    # 928cd8cc, 11:10 acceptance run): an unreadable mailfile is the
    # usage/parse bucket — rc 3, no TRUST: line.
    print(f"session-mail-verify: cannot read mail file: {e}", file=sys.stderr)
    sys.exit(3)
msg = email.message_from_string(raw)
hv = msg.get(SIGH)
if not hv:
    out(f"unsigned          : no {SIGH} header")
    if trust:
        # Under --trust an unverifiable mail is a REJECTION (plain mode
        # keeps rc 2 for this; rc 2 never occurs under --trust).
        trust_fail("1-signature", "unsigned")
    sys.exit(2)

params = dict(
    kv.strip().split("=", 1)
    for kv in " ".join(str(hv).split()).split(";")
    if "=" in kv
)
ns = params.get("n", MAIL_NS_DEFAULT)
try:
    armor = (BEGIN + "\n"
             + "\n".join(textwrap.wrap(params["b"].strip(), 70))
             + "\n" + END + "\n")
    blob, sig_ns = parse_sshsig(armor)
except Exception as e:
    print(f"signature parse failed: {e}", file=sys.stderr)
    if trust:
        # Fail closed under --trust: a signature header that cannot be
        # parsed is attacker-influenceable input (same principle as
        # cert-invalid) — it must not exit through the no-verdict rc-3
        # path. Plain mode keeps its rc 3 here, unchanged.
        trust_fail("1-signature", "bad-signature")
    sys.exit(3)
sid = sid_from_blob(blob)
keyline = f"{keytype(blob)} {base64.b64encode(blob).decode()}"
data = canonical(msg)

with tempfile.NamedTemporaryFile("w", suffix=".sig", delete=False) as f:
    f.write(armor)
    sigfile = f.name
with tempfile.NamedTemporaryFile("w", suffix=".allowed", delete=False) as f:
    f.write(f"{sid}@session {keyline}\n")
    allowed = f.name

# 1. cryptographic validity against the embedded key (no trust store needed)
p1 = subprocess.run(
    ["ssh-keygen", "-Y", "check-novalidate", "-n", ns, "-s", sigfile],
    input=data, capture_output=True,
)
# 2. principal binding: <derived-sid>@session must map to the embedded key
p2 = subprocess.run(
    ["ssh-keygen", "-Y", "verify", "-f", allowed, "-I", f"{sid}@session",
     "-n", ns, "-s", sigfile],
    input=data, capture_output=True,
)

ok = p1.returncode == 0 and p2.returncode == 0
out(f"signature-crypto  : {'✅ good' if p1.returncode == 0 else '❌ BAD'}")
out(f"principal-binding : {'✅ good' if p2.returncode == 0 else '❌ BAD'}")
out(f"signer-key        : {keyline}")
out(f"sender-session-id : {sid}")
if not ok:
    sys.stderr.write(p1.stderr.decode() + p2.stderr.decode())
    if trust:
        trust_fail("1-signature",
                   "bad-signature" if p1.returncode != 0 else "bad-principal")
    sys.exit(1)
if trust:
    run_trust(sid, keyline, msg)
sys.exit(0)
PYEOF
