#!/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 _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 _finish(att: dict, challenge: str, label: str) -> int:
    """Verify + store. Returns process exit code."""
    try:
        rec = webauthn.verify_registration(
            att["clientDataJSON"], att["attestationObject"], challenge,
            require_user_verification=True, label=label, 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)%s"
            % (rec["id"][:16] + "...", rec["alg"],
               (" label=%r" % label) if label else ""))
    _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)
    _spool_setup()
    challenge = webauthn.new_enroll_challenge()
    _write_challenge(challenge, args.label or "")
    url = "http://127.0.0.1:%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())
