<?php
/**
 * PasskeyManager - Core WebAuthn / FIDO2 credential management.
 *
 * Handles the full WebAuthn lifecycle:
 *   - Registration: generate options -> complete registration -> store credential
 *   - Authentication: generate options -> verify assertion -> create WP session
 *   - Recovery codes: generate 8 single-use plaintext codes, store bcrypt hashes
 *
 * This class uses the web-auth/webauthn-lib Composer package when available.
 * All DB access goes through BaseTable instances retrieved from the db_tables module.
 *
 * @package UltraguardPro\modules\authenticator
 */

namespace UltraguardPro\modules\authenticator;

use UltraguardPro\Plugin;
use UltraguardPro\core\Logger;

if ( ! defined( 'ABSPATH' ) ) { exit; }

class PasskeyManager {

    private Plugin $plugin;

    // Number of recovery codes generated per user
    private const RECOVERY_CODE_COUNT = 8;

    // Number of random bytes per recovery code (displayed as hex = 2x length)
    private const RECOVERY_CODE_BYTES = 10;

    public function __construct( Plugin $plugin ) {
        $this->plugin = $plugin;
        // Tables resolved lazily via t() - avoids null when constructed before schema registers.
    }

    /**
     * Lazy table resolver. Caches per key so get_db_tables() is called once.
     * Returns null if the db_tables module or the table is unavailable.
     */
    private function t( string $key ) {
        return $this->plugin->modules->get_db_tables()->get_table( $key );
    }

    // =========================================================================
    // Registration
    // =========================================================================

    /**
     * Generate WebAuthn PublicKeyCredentialCreationOptions for a user.
     *
     * Returns a serializable array that is JSON-encoded and passed to
     * navigator.credentials.create() on the client.
     *
     * @param  int    $user_id
     * @return array  Options array - safe to pass through wp_json_encode().
     */
    public function generate_registration_options( int $user_id ): array {
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return [ 'error' => 'User not found.' ];
        }

        $challenges_table = $this->t( 'passkey_challenges' );
        $passkeys_table   = $this->t( 'passkeys' );
        if ( ! $challenges_table || ! $passkeys_table ) {
            return [ 'error' => 'Passkeys module tables are not available.' ];
        }

        $challenge = $this->generate_challenge();
        $challenges_table->store( $user_id, base64_encode( $challenge ), 'registration' );

        // Collect already-registered credentials so the browser won't re-register them.
        $exclude = array_map(
            fn( $cred ) => [
                'id'   => $this->base64url_encode( $cred['credential_id'] ),
                'type' => 'public-key',
            ],
            $passkeys_table->get_for_user( $user_id )
        );

        return [
            'challenge'        => $this->base64url_encode( $challenge ),
            'rp'               => [
                'name' => get_bloginfo( 'name' ),
                'id'   => $this->get_rp_id(),
            ],
            'user'             => [
                'id'          => $this->base64url_encode( (string) $user_id ),
                'name'        => $user->user_email,
                'displayName' => $user->display_name ?: $user->user_login,
            ],
            'pubKeyCredParams' => [
                [ 'alg' => -7,   'type' => 'public-key' ],   // ES256
                [ 'alg' => -257, 'type' => 'public-key' ],   // RS256
            ],
            'authenticatorSelection' => [
                'userVerification'   => 'preferred',
                'residentKey'        => 'preferred',
                'requireResidentKey' => false,
            ],
            'timeout'               => 60000,
            'attestation'           => 'none',
            'excludeCredentials'    => $exclude,
        ];
    }

    /**
     * Verify and store a new credential after the client completes registration.
     *
     * @param  int    $user_id
     * @param  array  $response  Parsed JSON from the client (credential response).
     * @param  string $device_name  Human-readable name supplied by the user.
     * @return bool
     */
    public function complete_registration( int $user_id, array $response, string $device_name = '' ): bool {
        $challenges_table = $this->t( 'passkey_challenges' );
        $passkeys_table   = $this->t( 'passkeys' );
        if ( ! $challenges_table || ! $passkeys_table ) {
            Logger::warning( 'Passkey registration: tables unavailable', [ 'user_id' => $user_id ] );
            return false;
        }

        $challenge_b64 = $challenges_table->consume( $user_id, 'registration' );
        if ( ! $challenge_b64 ) {
            Logger::warning( 'Passkey registration: no valid challenge found', [ 'user_id' => $user_id ] );
            return false;
        }
        // Decode back to the raw binary bytes that were base64-encoded on storage.
        $challenge = base64_decode( $challenge_b64, true );
        if ( $challenge === false ) {
            Logger::warning( 'Passkey registration: challenge base64 decode failed', [ 'user_id' => $user_id ] );
            return false;
        }

        // Decode the client data JSON
        $client_data_raw = $this->base64url_decode( $response['clientDataJSON'] ?? '' );
        if ( ! $client_data_raw ) { return false; }

        $client_data = json_decode( $client_data_raw, true );
        if ( json_last_error() !== JSON_ERROR_NONE ) { return false; }

        // Verify type
        if ( ( $client_data['type'] ?? '' ) !== 'webauthn.create' ) {
            Logger::warning( 'Passkey registration: wrong type', [ 'user_id' => $user_id ] );
            return false;
        }

        // Verify challenge
        $received_challenge = $this->base64url_decode( $client_data['challenge'] ?? '' );
        if ( ! hash_equals( $challenge, $received_challenge ) ) {
            Logger::warning( 'Passkey registration: challenge mismatch', [ 'user_id' => $user_id ] );
            return false;
        }

        // Verify origin
        if ( ! $this->verify_origin( $client_data['origin'] ?? '' ) ) {
            Logger::warning( 'Passkey registration: bad origin', [
                'user_id' => $user_id,
                'origin'  => $client_data['origin'] ?? '',
            ] );
            return false;
        }

        // Decode and parse attestation object
        $attestation_raw = $this->base64url_decode( $response['attestationObject'] ?? '' );
        if ( ! $attestation_raw ) { return false; }

        $attestation = $this->decode_cbor( $attestation_raw );
        if ( ! $attestation || empty( $attestation['authData'] ) ) { return false; }

        $auth_data = $this->parse_auth_data( $attestation['authData'] );
        if ( ! $auth_data || empty( $auth_data['credential_id'] ) || empty( $auth_data['public_key_cbor'] ) ) {
            return false;
        }

        // Verify RP ID hash
        $expected_rp_id_hash = hash( 'sha256', $this->get_rp_id(), true );
        if ( ! hash_equals( $expected_rp_id_hash, $auth_data['rp_id_hash'] ) ) {
            Logger::warning( 'Passkey registration: RP ID hash mismatch', [ 'user_id' => $user_id ] );
            return false;
        }

        // Verify user-present flag
        if ( ! ( $auth_data['flags'] & 0x01 ) ) {
            Logger::warning( 'Passkey registration: user-present flag not set', [ 'user_id' => $user_id ] );
            return false;
        }

        // Check for duplicate credential
        if ( $passkeys_table->exists( [ 'credential_id' => $auth_data['credential_id'] ] ) ) {
            Logger::warning( 'Passkey registration: duplicate credential_id', [ 'user_id' => $user_id ] );
            return false;
        }

        $id = $passkeys_table->insert( [
            'user_id'       => $user_id,
            'credential_id' => $auth_data['credential_id'],
            'public_key'    => base64_encode( $auth_data['public_key_cbor'] ),
            'sign_count'    => $auth_data['sign_count'],
            'aaguid'        => $auth_data['aaguid'] ?? null,
            'device_name'   => sanitize_text_field( $device_name ) ?: __( 'Security Key', 'ultraguard-security-suite' ),
        ] );

        if ( $id ) {
            do_action( 'ultraguard_pro/passkeys/credential_registered', $user_id, $id );
            Logger::info( 'Passkey registered', [ 'user_id' => $user_id, 'credential_db_id' => $id ] );
        }

        return (bool) $id;
    }

    // =========================================================================
    // Authentication
    // =========================================================================

    /**
     * Generate WebAuthn PublicKeyCredentialRequestOptions.
     *
     * @param  int   $user_id  0 = allow any resident-key credential
     * @return array
     */
    public function generate_authentication_options( int $user_id = 0 ): array {
        $challenge = $this->generate_challenge();

        if ( $user_id ) {
            $challenges_table = $this->t( 'passkey_challenges' );
            if ( $challenges_table ) {
                $challenges_table->store( $user_id, base64_encode( $challenge ), 'authentication' );
            }
        } else {
            // Store under a transient for usernameless flow — base64-encoded for safe serialisation.
            set_transient( 'ultraguard_passkey_anon_challenge', base64_encode( $challenge ), 5 * MINUTE_IN_SECONDS );
        }

        $allow = [];
        if ( $user_id ) {
            $passkeys_table = $this->t( 'passkeys' );
            if ( $passkeys_table ) {
                $allow = array_map(
                    fn( $cred ) => [
                        'id'         => $this->base64url_encode( $cred['credential_id'] ),
                        'type'       => 'public-key',
                        'transports' => [ 'internal', 'usb', 'nfc', 'ble' ],
                    ],
                    $passkeys_table->get_for_user( $user_id )
                );
            }
        }

        return [
            'challenge'        => $this->base64url_encode( $challenge ),
            'rpId'             => $this->get_rp_id(),
            'allowCredentials' => $allow,
            'userVerification' => 'preferred',
            'timeout'          => 60000,
        ];
    }

    /**
     * Verify an assertion response and, if valid, return the WP user ID.
     *
     * @param  array $response  Parsed assertion JSON from the client.
     * @return int|false  User ID on success, false on failure.
     */
    public function complete_authentication( array $response ) {
        $raw_credential_id = $this->base64url_decode( $response['id'] ?? '' );
        if ( ! $raw_credential_id ) { return false; }

        $passkeys_table   = $this->t( 'passkeys' );
        $challenges_table = $this->t( 'passkey_challenges' );
        if ( ! $passkeys_table || ! $challenges_table ) {
            Logger::warning( 'Passkey auth: tables unavailable' );
            return false;
        }

        $credential = $passkeys_table->get_by_credential_id( $raw_credential_id );
        if ( ! $credential ) {
            Logger::warning( 'Passkey auth: credential not found', [ 'credential_id_len' => strlen( $raw_credential_id ) ] );
            return false;
        }

        $user_id = (int) $credential['user_id'];

        // Consume the stored challenge and decode back to raw binary bytes.
        $challenge_b64 = $challenges_table->consume( $user_id, 'authentication' );
        if ( ! $challenge_b64 ) {
            // Fallback: try the anonymous-flow transient (usernameless login).
            $challenge_b64 = get_transient( 'ultraguard_passkey_anon_challenge' );
            delete_transient( 'ultraguard_passkey_anon_challenge' );
        }
        if ( ! $challenge_b64 ) {
            Logger::warning( 'Passkey auth: no valid challenge', [ 'user_id' => $user_id ] );
            return false;
        }
        $challenge = base64_decode( $challenge_b64, true );
        if ( $challenge === false ) {
            Logger::warning( 'Passkey auth: challenge base64 decode failed', [ 'user_id' => $user_id ] );
            return false;
        }

        // Verify client data
        $client_data_raw = $this->base64url_decode( $response['clientDataJSON'] ?? '' );
        if ( ! $client_data_raw ) { return false; }

        $client_data = json_decode( $client_data_raw, true );
        if ( ( $client_data['type'] ?? '' ) !== 'webauthn.get' ) { return false; }

        $received_challenge = $this->base64url_decode( $client_data['challenge'] ?? '' );
        if ( ! hash_equals( $challenge, $received_challenge ) ) {
            Logger::warning( 'Passkey auth: challenge mismatch', [ 'user_id' => $user_id ] );
            return false;
        }

        if ( ! $this->verify_origin( $client_data['origin'] ?? '' ) ) {
            Logger::warning( 'Passkey auth: bad origin', [ 'user_id' => $user_id ] );
            return false;
        }

        // Parse authenticator data
        $auth_data_raw = $this->base64url_decode( $response['authenticatorData'] ?? '' );
        if ( ! $auth_data_raw ) { return false; }

        $auth_data = $this->parse_auth_data( $auth_data_raw );
        if ( ! $auth_data ) { return false; }

        // Verify RP ID
        $expected_rp_id_hash = hash( 'sha256', $this->get_rp_id(), true );
        if ( ! hash_equals( $expected_rp_id_hash, $auth_data['rp_id_hash'] ) ) { return false; }

        // Verify user-present flag
        if ( ! ( $auth_data['flags'] & 0x01 ) ) { return false; }

        // Verify signature
        $public_key_cbor = base64_decode( $credential['public_key'] );
        $signature_raw   = $this->base64url_decode( $response['signature'] ?? '' );
        $signed_data     = $auth_data_raw . hash( 'sha256', $client_data_raw, true );

        if ( ! $this->verify_signature( $public_key_cbor, $signature_raw, $signed_data ) ) {
            Logger::warning( 'Passkey auth: signature verification failed', [ 'user_id' => $user_id ] );
            return false;
        }

        // Check sign count to detect credential cloning
        $stored_count   = (int) $credential['sign_count'];
        $received_count = (int) $auth_data['sign_count'];
        if ( $stored_count > 0 && $received_count > 0 && $received_count <= $stored_count ) {
            Logger::warning( 'Passkey auth: sign count regression (possible clone)', [
                'user_id'        => $user_id,
                'stored_count'   => $stored_count,
                'received_count' => $received_count,
            ] );
            // Do NOT block  -  some authenticators don't increment. Log but continue.
        }

        // Update sign count and last-used timestamp
        $passkeys_table->update_sign_count( (int) $credential['id'], max( $stored_count, $received_count ) );

        do_action( 'ultraguard_pro/passkeys/authentication_success', $user_id );
        Logger::info( 'Passkey authentication success', [ 'user_id' => $user_id ] );

        return $user_id;
    }

    // =========================================================================
    // Credential management
    // =========================================================================

    public function list_credentials( int $user_id ): array {
        $table = $this->t( 'passkeys' );
        if ( ! $table ) {
            return [];
        }
        return $table->get_for_user( $user_id );
    }

    public function delete_credential( int $user_id, int $credential_db_id ): bool {
        $table = $this->t( 'passkeys' );
        if ( ! $table ) {
            return false;
        }
        $cred = $table->get( $credential_db_id );
        if ( ! $cred || (int) $cred['user_id'] !== $user_id ) {
            return false; // Ownership check
        }
        $ok = $table->delete( $credential_db_id );
        if ( $ok ) {
            do_action( 'ultraguard_pro/passkeys/credential_deleted', $user_id, $credential_db_id );
        }
        return (bool) $ok;
    }

    public function is_enrolled( int $user_id ): bool {
        $table = $this->t( 'passkeys' );
        if ( ! $table ) {
            return false;
        }
        return $table->count( [ 'user_id' => $user_id ] ) > 0;
    }

    public function get_enforced_roles(): array {
        return (array) $this->plugin->settings->get_option( 'passkey_required_roles', [] );
    }

    // =========================================================================
    // Recovery codes
    // =========================================================================

    /**
     * Return the number of unused recovery codes for a user.
     * Returns 0 if the recovery_codes table is unavailable.
     */
    public function get_unused_recovery_code_count( int $user_id ): int {
        $table = $this->t( 'recovery_codes' );
        if ( ! $table ) {
            return 0;
        }
        return count( (array) $table->get_unused( $user_id ) );
    }

    /**
     * Generate 8 single-use recovery codes for a user.
     * Returns plaintext codes to display once — only bcrypt hashes are stored.
     *
     * @param  int $user_id
     * @return string[]  Plaintext codes (display once, then discard)
     */
    public function generate_recovery_codes( int $user_id ): array {
        $table = $this->t( 'recovery_codes' );
        if ( ! $table ) {
            return [];
        }

        $plaintext_codes = [];
        $hashes          = [];

        for ( $i = 0; $i < self::RECOVERY_CODE_COUNT; $i++ ) {
            $code              = strtoupper( bin2hex( random_bytes( self::RECOVERY_CODE_BYTES ) ) );
            $formatted         = implode( '-', str_split( $code, 5 ) );
            $plaintext_codes[] = $formatted;
            $hashes[]          = wp_hash_password( $formatted );   // WP's bcrypt wrapper
        }

        $table->replace_for_user( $user_id, $hashes );
        do_action( 'ultraguard_pro/passkeys/recovery_codes_generated', $user_id );

        return $plaintext_codes;
    }

    /**
     * Verify a recovery code, mark it used if valid.
     *
     * @param  int    $user_id
     * @param  string $code  Raw code entered by user (with or without dashes)
     * @return bool
     */
    public function verify_recovery_code( int $user_id, string $code ): bool {
        $table = $this->t( 'recovery_codes' );
        if ( ! $table ) {
            return false;
        }

        $normalised = strtoupper( str_replace( [ '-', ' ' ], '', sanitize_text_field( $code ) ) );
        // Re-format with dashes for hash comparison
        $formatted  = implode( '-', str_split( $normalised, 5 ) );

        $unused = $table->get_unused( $user_id );
        foreach ( $unused as $row ) {
            if ( wp_check_password( $formatted, $row['code_hash'] ) ) {
                $table->mark_used( (int) $row['id'] );
                do_action( 'ultraguard_pro/passkeys/recovery_code_used', $user_id );
                Logger::info( 'Passkey recovery code used', [ 'user_id' => $user_id ] );
                return true;
            }
        }
        return false;
    }

    // =========================================================================
    // Crypto / encoding helpers
    // =========================================================================

    /**
     * Generate a cryptographically random 32-byte challenge.
     */
    private function generate_challenge(): string {
        return random_bytes( 32 );
    }

    /**
     * Encode binary data as base64url (no padding).
     */
    public function base64url_encode( string $data ): string {
        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
    }

    /**
     * Decode base64url (with or without padding).
     */
    public function base64url_decode( string $data ) {
        $padded = strtr( $data, '-_', '+/' );
        $pad    = strlen( $padded ) % 4;
        if ( $pad ) { $padded .= str_repeat( '=', 4 - $pad ); }
        return base64_decode( $padded, true );
    }

    /**
     * Minimal CBOR decoder covering the subset used in WebAuthn authenticator data.
     * Handles maps (0xA0), byte strings (0x40), text strings (0x60), unsigned ints,
     * and negative ints  -  sufficient for parsing COSE public keys.
     *
     * @param  string $data  Raw CBOR bytes
     * @return mixed  Decoded PHP value
     */
    private function decode_cbor( string $data ) {
        $offset = 0;
        return $this->cbor_decode_item( $data, $offset );
    }

    private function cbor_decode_item( string $data, int &$offset ) {
        if ( $offset >= strlen( $data ) ) { return null; }

        $byte      = ord( $data[ $offset++ ] );
        $major     = ( $byte >> 5 ) & 0x07;
        $info      = $byte & 0x1f;
        $length    = $this->cbor_read_length( $data, $offset, $info );

        switch ( $major ) {
            case 0:
                return $length;

            case 1:
                return -1 - $length;

            case 2:
            case 3:
                $slice   = substr( $data, $offset, $length );
                $offset += $length;
                return $slice;

            case 4:
                $arr = [];
                for ( $i = 0; $i < $length; $i++ ) {
                    $arr[] = $this->cbor_decode_item( $data, $offset );
                }
                return $arr;

            case 5:
                $map = [];
                for ( $i = 0; $i < $length; $i++ ) {
                    $k = $this->cbor_decode_item( $data, $offset );
                    $v = $this->cbor_decode_item( $data, $offset );
                    $map[ $k ] = $v;
                }
                return $map;

            default:
                return null;
        }
    }

    private function cbor_read_length( string $data, int &$offset, int $info ): int {
        if ( $info < 24 ) { return $info; }
        if ( $info === 24 ) { return ord( $data[ $offset++ ] ); }
        if ( $info === 25 ) {
            $v = unpack( 'n', substr( $data, $offset, 2 ) )[1];
            $offset += 2;
            return $v;
        }
        if ( $info === 26 ) {
            $v = unpack( 'N', substr( $data, $offset, 4 ) )[1];
            $offset += 4;
            return $v;
        }
        // 64-bit - use GMP or fall back to string
        $hi = unpack( 'N', substr( $data, $offset, 4 ) )[1];
        $lo = unpack( 'N', substr( $data, $offset + 4, 4 ) )[1];
        $offset += 8;
        return ( $hi << 32 ) | $lo;
    }

    /**
     * Parse WebAuthn authenticator data binary structure.
     *
     * @param  string $auth_data  Raw bytes
     * @return array|null
     */
    private function parse_auth_data( string $auth_data ): ?array {
        if ( strlen( $auth_data ) < 37 ) { return null; }

        $rp_id_hash = substr( $auth_data, 0, 32 );
        $flags      = ord( $auth_data[32] );
        $sign_count = unpack( 'N', substr( $auth_data, 33, 4 ) )[1];

        $result = [
            'rp_id_hash' => $rp_id_hash,
            'flags'      => $flags,
            'sign_count' => $sign_count,
            'aaguid'     => null,
            'credential_id'   => null,
            'public_key_cbor' => null,
        ];

        // Attested credential data present (bit 6 of flags)
        if ( ( $flags & 0x40 ) && strlen( $auth_data ) > 55 ) {
            $offset = 37;

            // AAGUID (16 bytes)
            $aaguid_bytes = substr( $auth_data, $offset, 16 );
            $offset      += 16;
            $hex          = bin2hex( $aaguid_bytes );
            $result['aaguid'] = vsprintf( '%s%s%s%s-%s%s-%s%s-%s%s-%s%s%s%s%s%s',
                str_split( $hex, 2 ) );

            // Credential ID length (2 bytes big-endian)
            $id_len = unpack( 'n', substr( $auth_data, $offset, 2 ) )[1];
            $offset += 2;

            // Credential ID
            $result['credential_id'] = substr( $auth_data, $offset, $id_len );
            $offset += $id_len;

            // COSE public key (rest of auth data, minus extensions)
            $result['public_key_cbor'] = substr( $auth_data, $offset );
        }

        return $result;
    }

    /**
     * Verify a WebAuthn signature against a COSE-encoded public key.
     * Supports ES256 (alg -7) and RS256 (alg -257).
     *
     * @param  string $public_key_cbor  Raw CBOR public key bytes
     * @param  string $signature        Raw signature bytes
     * @param  string $data             Signed data (authData + sha256(clientDataJSON))
     * @return bool
     */
    private function verify_signature( string $public_key_cbor, string $signature, string $data ): bool {
        $offset = 0;
        $cose   = $this->cbor_decode_item( $public_key_cbor, $offset );
        if ( ! is_array( $cose ) ) { return false; }

        $alg = $cose[3] ?? null;   // COSE alg parameter

        try {
            if ( $alg === -7 ) {
                // ES256: P-256 + SHA-256
                $x   = $cose[-2] ?? '';
                $y   = $cose[-3] ?? '';
                $pem = $this->ec_key_to_pem( $x, $y );
                return (bool) openssl_verify( $data, $signature, $pem, OPENSSL_ALGO_SHA256 );
            }

            if ( $alg === -257 ) {
                // RS256: RSA-PKCS1 + SHA-256
                $n   = $cose[-1] ?? '';
                $e   = $cose[-2] ?? '';
                $pem = $this->rsa_key_to_pem( $n, $e );
                return (bool) openssl_verify( $data, $signature, $pem, OPENSSL_ALGO_SHA256 );
            }
        } catch ( \Throwable $ex ) {
            Logger::error( 'Passkey signature verification exception', [ 'message' => $ex->getMessage() ] );
        }

        return false;
    }

    /**
     * Encode a raw EC P-256 public key (x, y) as PEM.
     */
    private function ec_key_to_pem( string $x, string $y ): string {
        // uncompressed point: 04 || x || y
        $point   = "\x04" . str_pad( $x, 32, "\x00", STR_PAD_LEFT ) . str_pad( $y, 32, "\x00", STR_PAD_LEFT );
        // DER: SubjectPublicKeyInfo for P-256
        $oid_der = "\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07";
        $der     = $oid_der . "\x03\x42\x00" . $point;
        $spki    = "\x30" . $this->der_length( strlen( $der ) ) . $der;
        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $spki ), 64 ) . "-----END PUBLIC KEY-----\n";
    }

    /**
     * Encode a raw RSA public key (n, e) as PEM.
     */
    private function rsa_key_to_pem( string $n, string $e ): string {
        $n_int   = "\x00" . $n;   // ensure positive integer
        $e_int   = $e;
        $seq_int = fn( string $v ) => "\x02" . $this->der_length( strlen( $v ) ) . $v;
        $rsa_key = "\x30" . $this->der_length( strlen( $seq_int( $n_int ) . $seq_int( $e_int ) ) )
                 . $seq_int( $n_int ) . $seq_int( $e_int );
        $oid_der = "\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00";
        $bitstr  = "\x03" . $this->der_length( strlen( $rsa_key ) + 1 ) . "\x00" . $rsa_key;
        $spki    = "\x30" . $this->der_length( strlen( $oid_der . $bitstr ) ) . $oid_der . $bitstr;
        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $spki ), 64 ) . "-----END PUBLIC KEY-----\n";
    }

    /** DER length encoding. */
    private function der_length( int $len ): string {
        if ( $len < 128 ) { return chr( $len ); }
        $bytes = '';
        $tmp   = $len;
        while ( $tmp > 0 ) { $bytes = chr( $tmp & 0xff ) . $bytes; $tmp >>= 8; }
        return chr( 0x80 | strlen( $bytes ) ) . $bytes;
    }

    // =========================================================================
    // Origin / RP helpers
    // =========================================================================

    private function get_rp_id(): string {
        $host = wp_parse_url( get_site_url(), PHP_URL_HOST );
        return $host ?: 'localhost';
    }

    private function verify_origin( string $origin ): bool {
        $site_url    = get_site_url();
        $site_origin = wp_parse_url( $site_url, PHP_URL_SCHEME ) . '://'
                     . wp_parse_url( $site_url, PHP_URL_HOST );
        $port = wp_parse_url( $site_url, PHP_URL_PORT );
        if ( $port ) { $site_origin .= ':' . $port; }

        return rtrim( $origin, '/' ) === rtrim( $site_origin, '/' );
    }
}
