#!/usr/bin/env python3
"""sandbroker-webauthn-enroll - host-only enrollment of a WebAuthn platform
authenticator (Touch ID / Windows Hello / Android) for sandbroker.

This is the ONLY path that registers a credential. It is deliberately a separate
human-run CLI, never reachable through the sandbox's /tmp bridge (which relays
only invoke + catalog). Registration is:

  * HOST-ONLY / physical-presence-gated. The ceremony's create() itself requires
    the operator's biometric on the platform authenticator, and this tool refuses
    to run from a sandbox context (see _guard_not_sandboxed below).
  * DIRECT-SOCKET gated. The tool probes sandbroker's direct UNIX socket with a
    direct-socket-only action (`status`). The srt sandbox's seccomp blocks
    AF_UNIX outright, so a sandboxed caller cannot make this probe succeed --
    that is the structural gate, on top of the env/netns heuristics.
  * NEVER bridge-relayed. The browser half of the ceremony runs against the
    host-netns dashboard (127.0.0.1), and the attestation is couriered back over
    the setgid claude-broker spool -- the same private channel the daemon and
    approver already share. Nothing here touches /tmp/sandbroker-bridge.

Flow (piggybacks on the running dashboard; see docs/integration-notes/webauthn.md):
  1. This tool writes a one-shot enrollment challenge to the enroll spool.
  2. In the dashboard (http://127.0.0.1:8765/?t=...), click "Enroll this device".
     The browser runs navigator.credentials.create() and POSTs the attestation;
     the dashboard drops it into the same spool.
  3. This tool reads the attestation, verifies it with sandbroker.webauthn, and
     stores the credential in <base>/etc/webauthn/credentials.json (0600).

Run it as the sandbroker user on the host so the store lands with the right owner:

    sudo -u sandbroker SANDBROKER_BASE=/opt/sandbroker bin/sandbroker-webauthn-enroll --label "MacBook Touch ID"

Dev/scratch trees (SANDBROKER_BASE under a user-owned dir) run it directly.
No secret values are ever printed.
"""
from __future__ import annotations

import argparse
import json
import os
import socket
import sys
import time
from pathlib import Path

# Import the verification core from the sibling package.
_REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_REPO))
from sandbroker import webauthn  # noqa: E402

BASE = os.environ.get("SANDBROKER_BASE", "/opt/sandbroker")
SOCKET_PATH = os.environ.get("SANDBROKER_SOCKET", os.path.join(BASE, "run", "sandbroker.sock"))
ENROLL_SPOOL = os.path.join(BASE, "run", "authz", "enroll")
CHALLENGE_FILE = os.path.join(ENROLL_SPOOL, "challenge.json")
ATTESTATION_FILE = os.path.join(ENROLL_SPOOL, "attestation.json")

DASHBOARD_PORT = os.environ.get("SANDBROKER_DASHBOARD_PORT", "8765")
POLL = 0.5
DEFAULT_TIMEOUT = 180

# Env markers that positively indicate a sandbox context. Any one present ->
# refuse. (The direct-socket probe below is the decisive gate; these are cheap
# early exits and documentation of intent.)
SANDBOX_ENV_MARKERS = ("SRT_SANDBOX", "SANDBROKER_SANDBOX", "SANDBROKER_IN_SANDBOX",
                       "IN_SRT_SANDBOX")


def _eprint(msg: str) -> None:
    sys.stderr.write(msg + "\n")
    sys.stderr.flush()


def _same_netns_as_init() -> bool:
    """True iff we share PID 1's network namespace. The srt sandbox uses
    --unshare-net, so a sandboxed process has a DIFFERENT netns from init. If we
    cannot read either link (some kernels/containers), fall through to True and
    let the direct-socket probe be the decider."""
    try:
        return os.readlink("/proc/self/ns/net") == os.readlink("/proc/1/ns/net")
    except OSError:
        return True


def _direct_socket_probe() -> bool:
    """Connect to the daemon's direct socket and issue a direct-socket-only
    action (`status`). Only a host process in the claude-broker group can do this;
    the sandbox's seccomp blocks AF_UNIX, so this is the structural sandbox gate."""
    try:
        conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        conn.settimeout(5)
        conn.connect(SOCKET_PATH)
        conn.sendall((json.dumps({"action": "status"}) + "\n").encode())
        buf = b""
        while b"\n" not in buf:
            chunk = conn.recv(65536)
            if not chunk:
                break
            buf += chunk
        conn.close()
        resp = json.loads(buf.decode("utf-8", "replace").strip() or "{}")
        return bool(resp.get("ok"))
    except (OSError, ValueError):
        return False


def _guard_not_sandboxed(require_socket: bool) -> None:
    """Refuse to run from a sandbox. Layered defense; any failure aborts."""
    hits = [m for m in SANDBOX_ENV_MARKERS if os.environ.get(m)]
    if hits:
        _eprint("refusing: sandbox env marker present (%s). Enrollment is host-only."
                % ", ".join(hits))
        raise SystemExit(3)
    if not _same_netns_as_init() and os.environ.get("SANDBROKER_ENROLL_ALLOW_NETNS") != "1":
        _eprint("refusing: not in the host network namespace (sandbox?). "
                "Enrollment is host-only. Set SANDBROKER_ENROLL_ALLOW_NETNS=1 only "
                "if you are certain this is the host.")
        raise SystemExit(3)
    if require_socket and not _direct_socket_probe():
        _eprint("refusing: cannot reach sandbroker over its direct socket (%s). "
                "Enrollment must run on the host, in the claude-broker group, "
                "with the daemon up. The sandbox cannot make this probe succeed."
                % SOCKET_PATH)
        raise SystemExit(3)


def _guard_can_store() -> None:
    """Refuse to start a ceremony we could not persist.

    The credential store lives under <base>/etc/webauthn, owned by the daemon uid
    and mode 0700, so this tool must run AS that uid. Running it as the operator
    lets the whole browser ceremony succeed and only then fails on the write --
    the biometric is done, the attestation is spooled, and nothing is enrolled.
    Check first and say exactly what to run instead."""
    d = os.path.dirname(webauthn.CREDENTIALS_FILE)
    if os.path.isdir(d) and os.access(d, os.W_OK):
        return
    if not os.path.isdir(d):
        parent = os.path.dirname(d)
        if os.access(parent, os.W_OK):
            return  # we can create it ourselves
    owner = ""
    try:
        import pwd
        owner = pwd.getpwuid(os.stat(d).st_uid).pw_name
    except Exception:
        pass
    _eprint("refusing: cannot write the credential store at %s%s." % (d, (" (owned by %s)" % owner) if owner else ""))
    _eprint("Enrollment must run as that user, or the ceremony completes and stores nothing:")
    _eprint("    sudo -u %s SANDBROKER_BASE=%s %s enroll"
            % (owner or "sandbroker", BASE, os.path.basename(sys.argv[0]) or "sandbroker-webauthn-enroll"))
    raise SystemExit(3)


def _spool_setup() -> None:
    os.makedirs(ENROLL_SPOOL, exist_ok=True)


def _write_challenge(challenge: str, label: str) -> None:
    payload = {
        "challenge": challenge,
        "rpId": webauthn.RP_ID,
        "user": {
            # A stable, non-secret RP-scoped user handle for this single human.
            "id": webauthn.b64u_encode(b"sandbroker-operator"),
            "name": "sandbroker operator",
            "displayName": "sandbroker operator",
        },
        "pubKeyCredParams": [
            {"type": "public-key", "alg": webauthn.COSE_ES256},
            {"type": "public-key", "alg": webauthn.COSE_EDDSA},
            {"type": "public-key", "alg": webauthn.COSE_RS256},
        ],
        "excludeCredentials": webauthn.exclude_credential_descriptors(),
        "authenticatorSelection": {
            "authenticatorAttachment": "platform",
            "residentKey": "preferred",
            "userVerification": "required",
        },
        "attestation": "none",
        "label": label,
        "created": int(time.time()),
        "expires": int(time.time()) + webauthn.ENROLL_WINDOW_TTL,
    }
    tmp = CHALLENGE_FILE + ".tmp"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o660)
    with os.fdopen(fd, "w") as fh:
        json.dump(payload, fh)
    os.replace(tmp, CHALLENGE_FILE)


def _read_attestation():
    try:
        with open(ATTESTATION_FILE) as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return None


def _cleanup() -> None:
    for p in (CHALLENGE_FILE, ATTESTATION_FILE):
        try:
            os.unlink(p)
        except OSError:
            pass


def _clean_name(s: str) -> str:
    """Bound a browser-supplied alias / human name: printable ASCII, <=64 chars."""
    s = "".join(c for c in str(s or "") if 0x20 <= ord(c) <= 0x7e).strip()
    return s[:64]


def _finish(att: dict, challenge: str, label: str) -> int:
    """Verify + store. Returns process exit code. Prefers the browser-supplied
    device alias / human name (from the enroll form) over the CLI --label."""
    dev_label = _clean_name(att.get("label")) or label
    human = _clean_name(att.get("humanName"))
    # The transports the browser actually reported for this authenticator. Storing
    # the real values (rather than assuming "internal") is what lets a phone or
    # security-key passkey be offered at login instead of silently filtered out.
    raw_t = att.get("transports")
    transports = [t for t in raw_t if isinstance(t, str) and t.isalpha()][:8] \
        if isinstance(raw_t, list) else []
    try:
        rec = webauthn.verify_registration(
            att["clientDataJSON"], att["attestationObject"], challenge,
            require_user_verification=True, label=dev_label, human_name=human,
            transports=transports, store=True,
        )
    except KeyError:
        _eprint("enrollment failed: attestation payload malformed")
        return 4
    except webauthn.WebAuthnError as e:
        _eprint("enrollment REJECTED: %s" % e.reason)
        return 4
    _eprint("enrolled credential %s (alg %s) device=%r name=%r rpId=%r transports=%s"
            % (rec["id"][:16] + "...", rec["alg"], dev_label, human,
               rec.get("rp_id"), rec.get("transports") or "(unreported)"))
    _eprint("stored in %s" % webauthn.CREDENTIALS_FILE)
    return 0


def cmd_enroll(args: argparse.Namespace) -> int:
    _guard_not_sandboxed(require_socket=not args.no_socket_probe)
    _guard_can_store()      # fail BEFORE the human does a biometric for nothing
    _spool_setup()
    # A previous run may have left an attestation behind (for example if it could
    # not store the credential). Clear it so we never verify a stale ceremony
    # against this run's challenge.
    try:
        os.unlink(ATTESTATION_FILE)
    except OSError:
        pass
    challenge = webauthn.new_enroll_challenge()
    _write_challenge(challenge, args.label or "")
    # localhost, not 127.0.0.1: WebAuthn rejects an IP-literal rpId, so the
    # ceremony must run against the localhost origin.
    url = "http://localhost:%s/?t=<token>" % DASHBOARD_PORT
    _eprint("")
    _eprint("Enrollment window open (%ds). In the sandbroker dashboard:" % webauthn.ENROLL_WINDOW_TTL)
    _eprint("  1. Open %s" % url)
    _eprint("     (exact URL incl. token: cat ~/.local/state/sandbroker-dashboard.url)")
    _eprint("  2. Click 'Enroll this device' and approve with your biometric.")
    _eprint("")
    _eprint("Waiting for the browser attestation...")
    deadline = time.time() + args.timeout
    try:
        while time.time() < deadline:
            att = _read_attestation()
            if att:
                rc = _finish(att, challenge, args.label or "")
                _cleanup()
                return rc
            time.sleep(POLL)
    except KeyboardInterrupt:
        _cleanup()
        _eprint("aborted.")
        return 130
    _cleanup()
    _eprint("timed out waiting for the browser ceremony.")
    return 5


def cmd_list(args: argparse.Namespace) -> int:
    creds = webauthn.load_credentials()
    if not creds:
        _eprint("no credentials enrolled.")
        return 0
    for cid, rec in creds.items():
        _eprint("%s  alg=%s  count=%s  label=%r  created=%s"
                % (cid[:20] + "...", rec.get("alg"), rec.get("sign_count"),
                   rec.get("label", ""), rec.get("created")))
    return 0


def cmd_remove(args: argparse.Namespace) -> int:
    _guard_not_sandboxed(require_socket=not args.no_socket_probe)
    creds = webauthn.load_credentials()
    match = [c for c in creds if c.startswith(args.prefix)]
    if not match:
        _eprint("no credential id starts with %r" % args.prefix)
        return 1
    if len(match) > 1:
        _eprint("ambiguous prefix; matches %d credentials" % len(match))
        return 1
    del creds[match[0]]
    webauthn._save_credentials(creds)
    _eprint("removed %s" % (match[0][:20] + "..."))
    return 0


def cmd_selftest(args: argparse.Namespace) -> int:
    """Drive the whole begin->finish path locally with a synthesized attestation,
    proving the CLI's verify+store logic without a browser. Uses a scratch base."""
    import tempfile
    import struct
    import hashlib
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.asymmetric import ec

    td = tempfile.mkdtemp(prefix="sandbroker-enroll-selftest-")
    webauthn.CREDENTIALS_FILE = os.path.join(td, "credentials.json")

    priv = ec.generate_private_key(ec.SECP256R1())
    nums = priv.public_key().public_numbers()
    x = nums.x.to_bytes(32, "big")
    y = nums.y.to_bytes(32, "big")

    def _cb(major, b):
        n = len(b)
        if n < 24:
            head = bytes([(major << 5) | n])
        else:
            head = bytes([(major << 5) | 24, n])
        return head + b

    cose = (bytes([0xA5]) + bytes([0x01, 0x02]) + bytes([0x03, 0x26])
            + bytes([0x20, 0x01]) + bytes([0x21]) + _cb(2, x)
            + bytes([0x22]) + _cb(2, y))
    cred_id = os.urandom(16)
    authdata = (webauthn._expected_rp_id_hash()
                + bytes([webauthn.FLAG_UP | webauthn.FLAG_UV | webauthn.FLAG_AT])
                + struct.pack(">I", 0) + (b"\x00" * 16)
                + struct.pack(">H", len(cred_id)) + cred_id + cose)
    att_obj = (bytes([0xA3]) + _cb(3, b"fmt") + _cb(3, b"none")
               + _cb(3, b"attStmt") + bytes([0xA0])
               + _cb(3, b"authData") + _cb(2, authdata))
    challenge = webauthn.new_enroll_challenge()
    client = json.dumps({"type": "webauthn.create", "challenge": challenge,
                         "origin": next(iter(webauthn.ALLOWED_ORIGINS))}).encode()
    att = {"clientDataJSON": webauthn.b64u_encode(client),
           "attestationObject": webauthn.b64u_encode(att_obj),
           "rawId": webauthn.b64u_encode(cred_id)}
    rc = _finish(att, challenge, "selftest-device")
    if rc == 0 and webauthn.has_credentials():
        _eprint("cli selftest: OK")
        return 0
    _eprint("cli selftest: FAILED")
    return 1


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(prog="sandbroker-webauthn-enroll",
                                 description="Host-only WebAuthn enrollment for sandbroker.")
    ap.add_argument("--no-socket-probe", action="store_true",
                    help="skip the direct-socket sandbox gate (dev scratch trees "
                         "with no running daemon only)")
    sub = ap.add_subparsers(dest="cmd")

    p_enroll = sub.add_parser("enroll", help="enroll a new platform authenticator")
    p_enroll.add_argument("--label", default="", help="human label for the device")
    p_enroll.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT,
                          help="seconds to wait for the browser ceremony")
    p_enroll.set_defaults(func=cmd_enroll)

    p_list = sub.add_parser("list", help="list enrolled credentials (no secrets)")
    p_list.set_defaults(func=cmd_list)

    p_rm = sub.add_parser("remove", help="remove a credential by id prefix")
    p_rm.add_argument("prefix", help="leading chars of the credential id")
    p_rm.set_defaults(func=cmd_remove)

    p_st = sub.add_parser("selftest", help="verify+store a synthesized attestation")
    p_st.set_defaults(func=cmd_selftest)

    args = ap.parse_args(argv)
    if not getattr(args, "func", None):
        # Default to enroll for the bare invocation.
        args.func = cmd_enroll
        args.label = ""
        args.timeout = DEFAULT_TIMEOUT
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
