HEX
Server: LiteSpeed
System: Linux s787.bom1.mysecurecloudhost.com 4.18.0-477.13.1.lve.el8.x86_64 #1 SMP Thu Jun 1 16:40:47 EDT 2023 x86_64
User: mobilech (5348)
PHP: 8.1.34
Disabled: NONE
Upload Files
File: /home/mobilech/nepalapplesupport.com/wp-content/plugins/wp-letsencrypt-ssl/classes/le-passkeys.php
<?php

if ( !defined( 'ABSPATH' ) ) {
    exit;
}
require_once dirname( __DIR__ ) . '/classes/credential-repository.php';
if ( class_exists( 'WPLE_Passkeys' ) ) {
    return;
}
class WPLE_Passkeys {
    public function __construct() {
        add_action( 'wp_ajax_wple_enable_passkey', [__CLASS__, 'wple_ajax_enable_passkey'] );
        add_action( 'wp_ajax_wple_remove_passkey', [__CLASS__, 'wple_ajax_remove_passkey'] );
        add_action( 'show_user_profile', [__CLASS__, 'render_profile_section'] );
        add_action( 'edit_user_profile', [__CLASS__, 'render_profile_section'] );
        add_action( 'wp_ajax_wple_get_register_options', [__CLASS__, 'ajax_get_register_options'] );
        add_action( 'wp_ajax_wple_register_passkey', [__CLASS__, 'ajax_register_passkey'] );
        add_action( 'wp_ajax_wple_passkey_login_options', [__CLASS__, 'wple_ajax_login_options'] );
        add_action( 'wp_ajax_nopriv_wple_passkey_login_options', [__CLASS__, 'wple_ajax_login_options'] );
        add_action( 'wp_ajax_wple_passkey_login_verify', [__CLASS__, 'wple_ajax_login_verify'] );
        add_action( 'wp_ajax_nopriv_wple_passkey_login_verify', [__CLASS__, 'wple_ajax_login_verify'] );
    }

    public static function wple_ajax_remove_passkey() {
        // Remove a passkey
        // Use check_ajax_referer to validate nonce and avoid undefined index notices.
        check_ajax_referer( 'wple-rempasskey', '_wpnonce' );
        $cred_id = ( isset( $_GET['cred_id'] ) ? intval( $_GET['cred_id'] ) : 0 );
        if ( !$cred_id ) {
            wp_die( 'ID is not provided!' );
            exit;
        }
        $uid = get_current_user_id();
        if ( !$uid ) {
            wp_die( 'Not authenticated!' );
            exit;
        }
        $credRepo = new Credential_Repository();
        $result = $credRepo->findOneByID( $cred_id );
        // Check if credential exists and belongs to the user (optional, can be enforced in delete method)
        if ( !$result ) {
            wp_die( 'Credential not found!' );
            exit;
        }
        if ( intval( $result['user_id'] ) !== intval( $uid ) ) {
            wp_die( 'You do not have permission to delete this credential!' );
            exit;
        }
        $go = $credRepo->deleteCredential( $cred_id );
        if ( $go ) {
            wp_redirect( admin_url( 'profile.php?removed=1#wple-passkeys' ) );
        } else {
            wp_die( 'Failed to delete credential!' );
        }
        exit;
    }

    public static function wple_ajax_enable_passkey() {
        // Enable / disable passkey feature
        // Use check_ajax_referer to validate nonce and avoid undefined index notices.
        check_ajax_referer( 'wple-adminjs', 'nc' );
        // Only allow users with manage_options capability (admins).
        if ( !current_user_can( 'manage_options' ) ) {
            exit( 'failed' );
        }
        // Sanitize / validate the enabled parameter; accept boolean-like values.
        $enabled = false;
        if ( isset( $_POST['enabled'] ) ) {
            $enabled = filter_var( $_POST['enabled'], FILTER_VALIDATE_BOOLEAN );
        }
        update_option( 'wple_passkeys', ( $enabled ? true : false ) );
        exit( 'success' );
    }

    public static function render_passkeys_table() {
        $uid = get_current_user_id();
        if ( !$uid ) {
            return true;
        }
        $credRepo = new Credential_Repository();
        $credentials = $credRepo->findAllByUserHandle( base64_encode( intval( $uid ) ) );
        ?>

        <table class="wp-list-table widefat striped table-view-list wple-passkeys" width="100%">
            <thead>
                <tr>
                    <th><?php 
        esc_html_e( 'Name', 'wp-letsencrypt-ssl' );
        ?></th>
                    <th><?php 
        esc_html_e( 'Created At', 'wp-letsencrypt-ssl' );
        ?></th>
                    <th><?php 
        esc_html_e( 'Last Used', 'wp-letsencrypt-ssl' );
        ?></th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                <?php 
        if ( empty( $credentials ) ) {
            echo '<tr><td colspan="4">' . esc_html__( 'No passkeys created yet.', 'wp-letsencrypt-ssl' ) . '</td></tr>';
        } else {
            foreach ( $credentials as $cred ) {
                ?>
                        <tr>
                            <td><?php 
                echo esc_html( $cred['device_name'] );
                ?></td>
                            <td><?php 
                echo esc_html( $cred['created_at'] );
                ?></td>
                            <td><?php 
                echo esc_html( $cred['last_used'] );
                ?></td>
                            <td>
                                <button class="button wple-remove-passkey"><a href="<?php 
                echo wp_nonce_url( admin_url( 'admin-ajax.php?action=wple_remove_passkey&cred_id=' . esc_attr( $cred['id'] ) ), 'wple-rempasskey' );
                ?>"><?php 
                esc_html_e( 'Remove', 'wp-letsencrypt-ssl' );
                ?></a></button>
                            </td>
                        </tr>
                <?php 
            }
        }
        ?>
            </tbody>
        </table>

    <?php 
    }

    public static function render_profile_section( $user ) {
        if ( !get_option( 'wple_passkeys' ) ) {
            return;
        }
        ?>
        <h2 id="wple-passkeys"><?php 
        esc_html_e( 'WP Encryption - Passkeys', 'wp-letsencrypt-ssl' );
        ?></h2>
        <?php 
        SELF::render_passkeys_table();
        ?>
        <table class="form-table">
            <tr>
                <td>
                    <div id="wple-passkey-area">
                        <button id="wple-register-passkey" class="button"><?php 
        esc_html_e( 'Register New Passkey', 'wp-letsencrypt-ssl' );
        ?></button>
                        <span id="wple-passkey-msg" style="margin-left:12px;line-height:30px;">
                            <?php 
        if ( isset( $_GET['removed'] ) ) {
            ?>
                                <i style="color:red;"><?php 
            esc_html_e( 'Passkey removed successfully!', 'wp-letsencrypt-ssl' );
            ?></i>
                            <?php 
        }
        ?>
                        </span>
                    </div>
                </td>
            </tr>
            <tr>
                <td>
                    <small>Passkeys are not a two-factor authentication (2FA) method. They are a passwordless multi-factor authentication (MFA) solution that allows users to securely log in without using a password - No passwords. No brute force. Just secure, frictionless login with passkey-based WebAuthn - powered by your browser and protected by fingerprint, Face ID, or device PIN.</small>
                </td>
            </tr>
        </table>
<?php 
    }

    public static function ajax_get_register_options() {
        if ( !wp_verify_nonce( $_REQUEST['nonce'] ?? '', 'wple-adminjs' ) ) {
            wp_send_json_error( 'Invalid nonce', 403 );
        }
        $uid = get_current_user_id();
        if ( !$uid ) {
            wp_send_json_error( 'Not authenticated', 403 );
        }
        $ttl = 300;
        // seconds
        $challenge = base64_encode( random_bytes( 32 ) );
        // Save challenge with expiry in user meta
        $meta = array(
            'challenge' => $challenge,
            'expires'   => time() + $ttl,
        );
        update_user_meta( $uid, 'wple_registration_challenge', $meta );
        $user = get_userdata( $uid );
        // Build options object. Client will convert base64 -> Uint8Array
        $options = array(
            'challenge'        => $challenge,
            'rp'               => array(
                'name' => get_bloginfo( 'name' ),
                'id'   => wp_parse_url( home_url(), PHP_URL_HOST ),
            ),
            'user'             => array(
                'id'          => base64_encode( intval( $uid ) ),
                'name'        => $user->user_login,
                'displayName' => ( $user->display_name ?: $user->user_login ),
            ),
            'pubKeyCredParams' => array(
                array(
                    'type' => 'public-key',
                    'alg'  => -7,
                ),
                // ES256
                array(
                    'type' => 'public-key',
                    'alg'  => -257,
                ),
            ),
            'timeout'          => 60000,
            'attestation'      => 'none',
        );
        // Exclude existing credentials
        $credRepo = new Credential_Repository();
        $existing = $credRepo->findAllByUserHandle( base64_encode( (string) $uid ) );
        if ( !empty( $existing ) ) {
            //free limit
            wp_send_json_error( 'You have reached the free limit for passkeys. Please upgrade for unlimited passkey registrations or delete existing ones.', 403 );
            $exclude = array();
            foreach ( $existing as $cred ) {
                $exclude[] = array(
                    'type' => 'public-key',
                    'id'   => $cred['credential_id'],
                );
            }
            $options['excludeCredentials'] = $exclude;
        }
        wp_send_json_success( $options );
    }

    public static function ajax_register_passkey() {
        if ( !wp_verify_nonce( $_POST['nonce'] ?? '', 'wple-adminjs' ) ) {
            wp_send_json_error( 'Invalid nonce', 403 );
        }
        $uid = get_current_user_id();
        if ( !$uid ) {
            wp_send_json_error( 'Not authenticated', 403 );
        }
        $challenge_sent = sanitize_text_field( $_POST['challenge'] ?? '' );
        // $id = sanitize_text_field($_POST['id'] ?? '');
        // $rawId = sanitize_text_field($_POST['rawId'] ?? '');
        // $type = sanitize_text_field($_POST['type'] ?? '');
        $device_name = sanitize_text_field( $_POST['deviceName'] ?? '' );
        $clientDataJSON_b64 = sanitize_text_field( $_POST['clientDataJSON'] ?? '' );
        $attestationObject_b64 = sanitize_text_field( $_POST['attestationObject'] ?? '' );
        if ( empty( $clientDataJSON_b64 ) || empty( $attestationObject_b64 ) ) {
            wp_send_json_error( 'Missing fields', 400 );
        }
        $meta = get_user_meta( $uid, 'wple_registration_challenge', true );
        if ( !is_array( $meta ) || empty( $meta['challenge'] ) || empty( $meta['expires'] ) || time() > intval( $meta['expires'] ) ) {
            wp_send_json_error( 'Challenge expired or missing', 400 );
        }
        if ( hash_equals( $meta['challenge'], $challenge_sent ) === false ) {
            wp_send_json_error( 'Challenge mismatch', 400 );
        }
        // Basic clientDataJSON checks (type + origin + challenge)
        $clientDataJson = base64_decode( $clientDataJSON_b64 );
        $clientData = json_decode( $clientDataJson, true );
        if ( !is_array( $clientData ) ) {
            wp_send_json_error( 'Invalid clientDataJSON', 400 );
        }
        if ( ($clientData['type'] ?? '') !== 'webauthn.create' ) {
            wp_send_json_error( 'Invalid clientData type', 400 );
        }
        $expected_origin = rtrim( home_url(), '/' );
        $origin = rtrim( $clientData['origin'] ?? '', '/' );
        if ( $origin !== $expected_origin && $origin !== rtrim( admin_url(), '/' ) ) {
            wp_send_json_error( 'Invalid origin', 400 );
        }
        //attestation checks
        $att_raw = SELF::wple_b64url_decode( $attestationObject_b64 );
        if ( !$att_raw ) {
            wp_send_json_error( array(
                'message' => 'Invalid attestation object encoding.',
            ) );
        }
        $offset = 0;
        $att_map = SELF::wple_cbor_decode( $att_raw, $offset );
        if ( !is_array( $att_map ) || !isset( $att_map['authData'] ) ) {
            wp_send_json_error( array(
                'message' => 'Invalid attestation object structure.',
            ) );
        }
        $auth = SELF::wple_parse_auth_data( $att_map['authData'] );
        if ( !$auth ) {
            wp_send_json_error( array(
                'message' => 'Could not parse authenticator data.',
            ) );
        }
        if ( !SELF::wple_verify_rp_id_hash( $auth['rpIdHash'] ) ) {
            wp_send_json_error( array(
                'message' => 'RP ID mismatch.',
            ) );
        }
        if ( !$auth['userPresent'] ) {
            wp_send_json_error( array(
                'message' => 'User presence flag not set.',
            ) );
        }
        if ( !$auth['credentialId'] || !$auth['publicKeyCbor'] ) {
            wp_send_json_error( array(
                'message' => 'No credential data in authenticator response.',
            ) );
        }
        $pem = SELF::wple_cose_to_pem( $auth['publicKeyCbor'] );
        if ( !$pem ) {
            wp_send_json_error( array(
                'message' => 'Unsupported public key algorithm.',
            ) );
        }
        $cred_id = SELF::wple_b64url_encode( $auth['credentialId'] );
        $credRepo = new Credential_Repository();
        $saved = $credRepo->save_credential(
            $uid,
            $cred_id,
            $auth['publicKeyCbor'],
            (int) $auth['signCount'],
            $device_name
        );
        if ( !$saved ) {
            wp_send_json_error( array(
                'message' => 'Could not save credential (may already be registered).',
            ) );
        }
        wp_send_json_success( array(
            'message'      => 'Passkey registered!',
            'credentialId' => $cred_id,
        ) );
        exit;
    }

    // For anonymous users (login): key by a random token passed back by the client.
    static function wple_challenge_create_token( $type, $token ) {
        $raw = random_bytes( 32 );
        set_transient( 'wple_' . $type . '_tok_' . $token, SELF::wple_b64url_encode( $raw ), 9000 );
        return SELF::wple_b64url_encode( $raw );
    }

    static function wple_challenge_get_token( $type, $token ) {
        $key = 'wple_' . $type . '_tok_' . sanitize_key( $token );
        $raw = get_transient( $key );
        //b64 url encoded
        delete_transient( $key );
        return ( $raw ? $raw : null );
    }

    static function wple_get_rp_id() {
        return wp_parse_url( home_url(), PHP_URL_HOST );
    }

    static function wple_ajax_login_options() {
        check_ajax_referer( 'wple-loginjs', 'nonce' );
        $user_login = ( isset( $_POST['user_login'] ) ? sanitize_text_field( $_POST['user_login'] ) : '' );
        $allow_creds = array();
        $lookup_user_id = 0;
        if ( $user_login ) {
            $u = get_user_by( 'login', $user_login );
            if ( !$u ) {
                $u = get_user_by( 'email', $user_login );
            }
            if ( $u ) {
                $lookup_user_id = (int) $u->ID;
                $credDB = new Credential_Repository();
                foreach ( $credDB->get_user_credentials( $lookup_user_id ) as $c ) {
                    $allow_creds[] = array(
                        'type' => 'public-key',
                        'id'   => $c['credential_id'],
                    );
                }
            }
        }
        // Use a transient keyed to a random token stored client-side as well,
        // so anonymous users can also get a per-request challenge.
        $token = wp_generate_password( 32, false );
        $challenge = SELF::wple_challenge_create_token( 'login', $token );
        $options = array(
            'rpId'             => SELF::wple_get_rp_id(),
            'challenge'        => $challenge,
            'challengeToken'   => $token,
            'timeout'          => 60000,
            'userVerification' => 'preferred',
            'allowCredentials' => $allow_creds,
            'ch'               => 'wple_login_tok_' . $token,
        );
        wp_send_json_success( $options );
    }

    static function wple_get_json_body() {
        $raw = file_get_contents( 'php://input' );
        if ( $raw ) {
            $decoded = json_decode( $raw, true );
            if ( is_array( $decoded ) ) {
                return $decoded;
            }
        }
        return ( is_array( $_POST ) ? $_POST : array() );
    }

    static function wple_ajax_login_verify() {
        check_ajax_referer( 'wple-loginjs', 'nonce' );
        $body = SELF::wple_get_json_body();
        $cred_id = ( isset( $body['credentialId'] ) ? sanitize_text_field( trim( $body['credentialId'] ) ) : '' );
        $cdj = ( isset( $body['clientDataJSON'] ) ? sanitize_text_field( trim( $body['clientDataJSON'] ) ) : '' );
        $auth_data = ( isset( $body['authenticatorData'] ) ? sanitize_text_field( trim( $body['authenticatorData'] ) ) : '' );
        $sig = ( isset( $body['signature'] ) ? sanitize_text_field( trim( $body['signature'] ) ) : '' );
        $token = ( isset( $body['challengeToken'] ) ? sanitize_text_field( trim( $body['challengeToken'] ) ) : '' );
        if ( !$cred_id || !$cdj || !$auth_data || !$sig || !$token ) {
            wp_send_json_error( array(
                'message' => 'Missing assertion fields.',
            ) );
        }
        $credDB = new Credential_Repository();
        $stored = $credDB->findOneByCredentialId( $cred_id );
        if ( !$stored ) {
            wp_send_json_error( array(
                'message' => 'Unknown credential.',
            ) );
        }
        $raw_challenge = SELF::wple_challenge_get_token( 'login', $token );
        if ( !$raw_challenge ) {
            wp_send_json_error( array(
                'message' => 'Challenge expired. Please try again.',
            ) );
        }
        if ( !SELF::wple_verify_client_data( $cdj, 'webauthn.get', $raw_challenge ) ) {
            wp_send_json_error( array(
                'message' => 'Client data verification failed.',
            ) );
        }
        $raw_auth = SELF::wple_b64url_decode( $auth_data );
        $parsed = SELF::wple_parse_auth_data( $raw_auth );
        if ( !$parsed ) {
            wp_send_json_error( array(
                'message' => 'Could not parse authenticator data.',
            ) );
        }
        if ( !SELF::wple_verify_rp_id_hash( $parsed['rpIdHash'] ) ) {
            wp_send_json_error( array(
                'message' => 'RP ID mismatch.',
            ) );
        }
        if ( !$parsed['userPresent'] ) {
            wp_send_json_error( array(
                'message' => 'User presence flag not set.',
            ) );
        }
        // signed_data = authData || SHA-256(clientDataJSON_bytes)
        $cdj_hash = hash( 'sha256', SELF::wple_b64url_decode( $cdj ), true );
        $signed_data = $raw_auth . $cdj_hash;
        $pub_cbor = base64_decode( $stored['public_key'] );
        $pem = SELF::wple_cose_to_pem( $pub_cbor );
        if ( !$pem ) {
            wp_send_json_error( array(
                'message' => 'Stored public key is invalid.',
            ) );
        }
        $pub_key_res = openssl_get_publickey( $pem );
        if ( !$pub_key_res ) {
            wp_send_json_error( array(
                'message' => 'Could not load public key.',
            ) );
        }
        $signature = SELF::wple_b64url_decode( $sig );
        $verified = openssl_verify(
            $signed_data,
            $signature,
            $pub_key_res,
            OPENSSL_ALGO_SHA256
        );
        if ( $verified !== 1 ) {
            wp_send_json_error( array(
                'message' => 'Signature verification failed.',
            ) );
        }
        $new_count = (int) $parsed['signCount'];
        if ( $new_count !== 0 && $new_count <= (int) $stored['sign_count'] ) {
            wp_send_json_error( array(
                'message' => 'Possible replay attack.',
            ) );
        }
        $credDB->wple_db_update_sign_count( $cred_id, $new_count );
        $user_id = (int) $stored['user_id'];
        $user = get_user_by( 'id', $user_id );
        if ( !$user ) {
            wp_send_json_error( array(
                'message' => 'Associated user not found.',
            ) );
        }
        wp_set_current_user( $user_id );
        wp_set_auth_cookie( $user_id, true );
        do_action( 'wp_login', $user->user_login, $user );
        $redirect = apply_filters( 'wple_login_redirect', admin_url(), $user );
        wp_send_json_success( array(
            'message'  => 'Logged in!',
            'redirect' => $redirect,
        ) );
    }

    /**
     * Convert base64url (no padding) to standard base64 with padding.
     */
    // function wple_base64url_to_base64($input)
    // {
    //     $b64 = strtr($input, '-_', '+/');
    //     $pad = strlen($b64) % 4;
    //     if ($pad) {
    //         $b64 .= str_repeat('=', 4 - $pad);
    //     }
    //     return $b64;
    // }
    // ============================================================
    // Base64URL helpers
    // ============================================================
    static function wple_b64url_encode( $data ) {
        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
    }

    static function wple_b64url_decode( $data ) {
        if ( !$data ) {
            return false;
        }
        $rem = strlen( $data ) % 4;
        $pad = ( $rem ? str_repeat( '=', 4 - $rem ) : '' );
        return base64_decode( strtr( $data, '-_', '+/' ) . $pad );
    }

    // ============================================================
    // WebAuthn verification helpers
    // ============================================================
    static function wple_verify_client_data( $cdj_b64url, $expected_type, $raw_challenge ) {
        $decoded = SELF::wple_b64url_decode( $cdj_b64url );
        if ( !$decoded ) {
            return false;
        }
        $json = json_decode( $decoded, true );
        if ( !is_array( $json ) ) {
            return false;
        }
        if ( (( isset( $json['type'] ) ? $json['type'] : '' )) !== $expected_type ) {
            return false;
        }
        $received = ( isset( $json['challenge'] ) ? $json['challenge'] : '' );
        if ( !$received || !hash_equals( $raw_challenge, $received ) ) {
            return false;
        }
        $expected_origin = rtrim( home_url(), '/' );
        $received_origin = rtrim( ( isset( $json['origin'] ) ? $json['origin'] : '' ), '/' );
        if ( $received_origin !== $expected_origin ) {
            return false;
        }
        return true;
    }

    static function wple_parse_auth_data( $auth_data ) {
        if ( strlen( $auth_data ) < 37 ) {
            return null;
        }
        $offset = 0;
        $rp_id_hash = substr( $auth_data, 0, 32 );
        $offset = 32;
        $flags_byte = ord( $auth_data[32] );
        $offset = 33;
        $sign_count_raw = substr( $auth_data, 33, 4 );
        if ( strlen( $sign_count_raw ) < 4 ) {
            return null;
        }
        $sign_count = unpack( 'N', $sign_count_raw );
        $sign_count = $sign_count[1];
        $offset = 37;
        $result = array(
            'rpIdHash'         => $rp_id_hash,
            'userPresent'      => (bool) ($flags_byte & 0x1),
            'userVerified'     => (bool) ($flags_byte & 0x4),
            'attestedCredData' => (bool) ($flags_byte & 0x40),
            'signCount'        => $sign_count,
            'credentialId'     => null,
            'publicKeyCbor'    => null,
        );
        if ( $result['attestedCredData'] ) {
            if ( strlen( $auth_data ) < $offset + 18 ) {
                return $result;
            }
            $offset += 16;
            // AAGUID
            $len_raw = substr( $auth_data, $offset, 2 );
            if ( strlen( $len_raw ) < 2 ) {
                return $result;
            }
            $cred_id_len = unpack( 'n', $len_raw );
            $cred_id_len = $cred_id_len[1];
            $offset += 2;
            if ( strlen( $auth_data ) < $offset + $cred_id_len ) {
                return $result;
            }
            $result['credentialId'] = substr( $auth_data, $offset, $cred_id_len );
            $offset += $cred_id_len;
            $result['publicKeyCbor'] = substr( $auth_data, $offset );
        }
        return $result;
    }

    static function wple_verify_rp_id_hash( $rp_id_hash ) {
        if ( !is_string( $rp_id_hash ) || strlen( $rp_id_hash ) !== 32 ) {
            return false;
        }
        $expected = hash( 'sha256', wp_parse_url( home_url(), PHP_URL_HOST ), true );
        return hash_equals( $expected, $rp_id_hash );
    }

    // ============================================================
    // CBOR decoder  (recursive; handles all types needed by WebAuthn)
    // ============================================================
    static function wple_cbor_decode( $data, &$offset ) {
        $len = strlen( $data );
        if ( $offset >= $len ) {
            return null;
        }
        $byte = ord( $data[$offset] );
        $major = $byte >> 5 & 0x7;
        $info = $byte & 0x1f;
        $offset++;
        // Decode argument (length or value)
        if ( $info < 24 ) {
            $arg = $info;
        } elseif ( $info === 24 ) {
            if ( $offset >= $len ) {
                return null;
            }
            $arg = ord( $data[$offset] );
            $offset++;
        } elseif ( $info === 25 ) {
            if ( $offset + 2 > $len ) {
                return null;
            }
            $tmp = unpack( 'n', substr( $data, $offset, 2 ) );
            $arg = $tmp[1];
            $offset += 2;
        } elseif ( $info === 26 ) {
            if ( $offset + 4 > $len ) {
                return null;
            }
            $tmp = unpack( 'N', substr( $data, $offset, 4 ) );
            $arg = $tmp[1];
            $offset += 4;
        } elseif ( $info === 27 ) {
            if ( $offset + 8 > $len ) {
                return null;
            }
            $hi_arr = unpack( 'N', substr( $data, $offset, 4 ) );
            $lo_arr = unpack( 'N', substr( $data, $offset + 4, 4 ) );
            $arg = $hi_arr[1] * 4294967296.0 + $lo_arr[1];
            $offset += 8;
        } else {
            return null;
            // indefinite/reserved
        }
        switch ( $major ) {
            case 0:
                // unsigned int
                return (int) $arg;
            case 1:
                // negative int
                return -1 - (int) $arg;
            case 2:
                // byte string
                if ( $offset + $arg > $len ) {
                    return null;
                }
                $bytes = substr( $data, $offset, $arg );
                $offset += $arg;
                return $bytes;
            case 3:
                // text string (UTF-8)
                if ( $offset + $arg > $len ) {
                    return null;
                }
                $str = substr( $data, $offset, $arg );
                $offset += $arg;
                return $str;
            case 4:
                // array
                $arr = array();
                for ($i = 0; $i < $arg; $i++) {
                    $arr[] = SELF::wple_cbor_decode( $data, $offset );
                }
                return $arr;
            case 5:
                // map
                $map = array();
                for ($i = 0; $i < $arg; $i++) {
                    $key = SELF::wple_cbor_decode( $data, $offset );
                    $val = SELF::wple_cbor_decode( $data, $offset );
                    if ( $key !== null ) {
                        $map[$key] = $val;
                    }
                }
                return $map;
            case 6:
                // semantic tag – decode wrapped value
                return SELF::wple_cbor_decode( $data, $offset );
            default:
                return null;
        }
    }

    // ============================================================
    // COSE public key → PEM
    // ============================================================
    static function wple_cose_to_pem( $cbor ) {
        if ( !$cbor ) {
            return null;
        }
        $offset = 0;
        $map = SELF::wple_cbor_decode( $cbor, $offset );
        if ( !is_array( $map ) ) {
            return null;
        }
        $kty = ( isset( $map[1] ) ? $map[1] : null );
        if ( $kty === 2 ) {
            // EC P-256  (alg -7 = ES256)
            $crv = ( isset( $map[-1] ) ? $map[-1] : null );
            $x = ( isset( $map[-2] ) ? $map[-2] : null );
            $y = ( isset( $map[-3] ) ? $map[-3] : null );
            if ( $crv !== 1 || !is_string( $x ) || !is_string( $y ) ) {
                return null;
            }
            return SELF::wple_ec_pem( $x, $y );
        }
        if ( $kty === 3 ) {
            // RSA  (alg -257 = RS256)
            $n = ( isset( $map[-1] ) ? $map[-1] : null );
            $e = ( isset( $map[-2] ) ? $map[-2] : null );
            if ( !is_string( $n ) || !is_string( $e ) ) {
                return null;
            }
            return SELF::wple_rsa_pem( $n, $e );
        }
        return null;
    }

    static function wple_der_len( $len ) {
        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;
    }

    static function wple_ec_pem( $x, $y ) {
        $oid_ec = "*\x86H\xce=\x02\x01";
        $oid_p256 = "*\x86H\xce=\x03\x01\x07";
        $alg = "0" . SELF::wple_der_len( 2 + strlen( $oid_ec ) + 2 + strlen( $oid_p256 ) ) . "\x06" . SELF::wple_der_len( strlen( $oid_ec ) ) . $oid_ec . "\x06" . SELF::wple_der_len( strlen( $oid_p256 ) ) . $oid_p256;
        $pt = "\x04" . $x . $y;
        $bit = "\x03" . SELF::wple_der_len( strlen( $pt ) + 1 ) . "\x00" . $pt;
        $spki = "0" . SELF::wple_der_len( strlen( $alg ) + strlen( $bit ) ) . $alg . $bit;
        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $spki ), 64, "\n" ) . "-----END PUBLIC KEY-----\n";
    }

    static function wple_rsa_pem( $n, $e ) {
        if ( ord( $n[0] ) & 0x80 ) {
            $n = "\x00" . $n;
        }
        if ( ord( $e[0] ) & 0x80 ) {
            $e = "\x00" . $e;
        }
        $rsa = "0" . SELF::wple_der_len( 2 + strlen( $n ) + 2 + strlen( $e ) ) . "\x02" . SELF::wple_der_len( strlen( $n ) ) . $n . "\x02" . SELF::wple_der_len( strlen( $e ) ) . $e;
        $oid = "*\x86H\x86\xf7\r\x01\x01\x01";
        $alg = "0" . SELF::wple_der_len( 2 + strlen( $oid ) + 2 ) . "\x06" . SELF::wple_der_len( strlen( $oid ) ) . $oid . "\x05\x00";
        $bit = "\x03" . SELF::wple_der_len( strlen( $rsa ) + 1 ) . "\x00" . $rsa;
        $spki = "0" . SELF::wple_der_len( strlen( $alg ) + strlen( $bit ) ) . $alg . $bit;
        return "-----BEGIN PUBLIC KEY-----\n" . chunk_split( base64_encode( $spki ), 64, "\n" ) . "-----END PUBLIC KEY-----\n";
    }

}