0;
}
private const META_KEY = '_acemedia_passkeys';
private const TRANSIENT_REGISTER = 'acemedia_passkey_register_';
private const TRANSIENT_LOGIN = 'acemedia_passkey_login_';
private const TRANSIENT_2FA = 'acemedia_passkey_2fa_';
private const TRANSIENT_PASSWORD_FACTOR = 'acemedia_passkey_pwd_';
/**
* Passkeys need OpenSSL and the WebAuthn library. The main plugin file defines the shared
* check; fall back to the same test if this class is ever loaded on its own.
*/
private static function runtime_available() {
if (function_exists('acemedia_login_block_passkeys_available')) {
return acemedia_login_block_passkeys_available();
}
return extension_loaded('openssl') && class_exists(WebAuthn::class);
}
public function __construct() {
// Without OpenSSL passkeys cannot be verified, so register no routes, forms or filters.
if (!self::runtime_available()) {
return;
}
add_action('rest_api_init', [$this, 'register_routes']);
add_action('acemedia_passkey_claim_cleanup', [__CLASS__, 'cleanup_expired_claims']);
add_action('login_form', [$this, 'render_login_ui'], 20);
add_action('show_user_profile', [$this, 'render_profile_ui'], 20);
add_action('edit_user_profile', [$this, 'render_profile_ui'], 20);
add_filter('authenticate', [$this, 'authenticate_with_passkey_token'], 15, 3);
}
public function register_routes() {
register_rest_route('acemedia/v1', '/passkeys/register-options', [
'methods' => 'POST',
'callback' => [$this, 'begin_registration'],
'permission_callback' => [$this, 'require_logged_in'],
]);
register_rest_route('acemedia/v1', '/passkeys/register', [
'methods' => 'POST',
'callback' => [$this, 'finish_registration'],
'permission_callback' => [$this, 'require_logged_in'],
]);
register_rest_route('acemedia/v1', '/passkeys/login-options', [
'methods' => 'POST',
'callback' => [$this, 'begin_login'],
'permission_callback' => '__return_true',
]);
register_rest_route('acemedia/v1', '/passkeys/login', [
'methods' => 'POST',
'callback' => [$this, 'finish_login'],
'permission_callback' => '__return_true',
]);
register_rest_route('acemedia/v1', '/passkeys/remove', [
'methods' => 'POST',
'callback' => [$this, 'remove_passkey'],
'permission_callback' => [$this, 'require_logged_in'],
]);
}
public function render_login_ui() {
if (!$this->is_enabled()) {
return;
}
?>
is_enabled()) {
return;
}
$passkeys = $this->get_passkeys($user->ID);
?>
is_enabled()) {
return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]);
}
$user = wp_get_current_user();
if (!$user || !$user->ID) {
return new \WP_Error('not_logged_in', __('Not logged in.', 'acemedia-login-block'), ['status' => 401]);
}
$passkeys = $this->get_passkeys($user->ID);
$exclude = [];
foreach ($passkeys as $passkey) {
if (!empty($passkey['id'])) {
$exclude[] = ByteBuffer::fromBase64Url($passkey['id']);
}
}
$webauthn = $this->get_webauthn();
$createArgs = $webauthn->getCreateArgs(
(string) $user->ID,
$user->user_login,
$user->display_name ?: $user->user_login,
60,
'required',
'preferred',
null,
$exclude
);
$state = $this->generate_state();
$this->store_state(self::TRANSIENT_REGISTER, $state, [
'user_id' => $user->ID,
'challenge' => $this->buffer_to_base64url($webauthn->getChallenge()),
], 10 * MINUTE_IN_SECONDS);
return rest_ensure_response([
'options' => $createArgs,
'state' => $state,
]);
}
public function finish_registration($request) {
if (!$this->is_enabled()) {
return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]);
}
$user = wp_get_current_user();
if (!$user || !$user->ID) {
return new \WP_Error('not_logged_in', __('Not logged in.', 'acemedia-login-block'), ['status' => 401]);
}
$state = sanitize_text_field($request->get_param('state'));
$state_data = $this->get_state(self::TRANSIENT_REGISTER, $state);
if (!$state_data || (int) $state_data['user_id'] !== (int) $user->ID) {
return new \WP_Error('invalid_state', __('Registration session expired.', 'acemedia-login-block'), ['status' => 400]);
}
$credential = $request->get_param('credential');
if (!is_array($credential) || empty($credential['response'])) {
return new \WP_Error('invalid_credential', __('Invalid credential payload.', 'acemedia-login-block'), ['status' => 400]);
}
$clientDataJSON = $this->base64url_decode($credential['response']['clientDataJSON'] ?? '');
$attestationObject = $this->base64url_decode($credential['response']['attestationObject'] ?? '');
$challenge = $this->base64url_decode($state_data['challenge']);
if (!$clientDataJSON || !$attestationObject) {
return new \WP_Error('invalid_payload', __('Invalid registration payload.', 'acemedia-login-block'), ['status' => 400]);
}
try {
$webauthn = $this->get_webauthn();
$createData = $webauthn->processCreate($clientDataJSON, $attestationObject, $challenge, false, true);
} catch (\Exception $e) {
return new \WP_Error('registration_failed', $e->getMessage(), ['status' => 400]);
}
$credential_id = $this->extract_credential_id($createData);
$public_key = $this->extract_public_key($createData);
$counter = $this->extract_counter($createData, $webauthn);
if (!$credential_id || !$public_key) {
return new \WP_Error('registration_failed', __('Could not read passkey data.', 'acemedia-login-block'), ['status' => 400]);
}
$name = sanitize_text_field($request->get_param('name')) ?: __('Passkey', 'acemedia-login-block');
$transports = $credential['transports'] ?? [];
$this->save_passkey($user->ID, [
'id' => $credential_id,
'publicKey' => $public_key,
'counter' => $counter,
'name' => $name,
'created' => time(),
'last_used' => 0,
'transports' => is_array($transports) ? $transports : [],
]);
Logging::log_event($user->ID, 'passkey_registered', [
'name' => $name,
'transports' => is_array($transports) ? $transports : [],
], true);
$this->delete_state(self::TRANSIENT_REGISTER, $state);
return rest_ensure_response(['success' => true]);
}
public function begin_login($request) {
if (!$this->is_enabled()) {
return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]);
}
$username = sanitize_text_field($request->get_param('username'));
$context = sanitize_text_field($request->get_param('context')) ?: 'passwordless';
$discoverable = (bool) $request->get_param('discoverable');
if (!in_array($context, ['passwordless', 'password_factor', 'second_factor'], true)) {
return new \WP_Error('invalid_context', __('Invalid passkey login context.', 'acemedia-login-block'), ['status' => 400]);
}
$user = null;
$allow_list = [];
if (!$username) {
if (!$discoverable) {
return new \WP_Error('missing_username', __('Username is required.', 'acemedia-login-block'), ['status' => 400]);
}
} else {
$user = $this->get_user_from_login($username);
if (!$user) {
return new \WP_Error('invalid_username', __('Invalid username.', 'acemedia-login-block'), ['status' => 404]);
}
if ($this->is_locked_out($user->ID)) {
return new \WP_Error('locked_out', __('Account is temporarily locked.', 'acemedia-login-block'), ['status' => 403]);
}
$requires_2fa = $this->user_requires_2fa($user);
if ($context === 'passwordless' && $requires_2fa && !$this->user_allows_passwordless($user)) {
return new \WP_Error('passkey_requires_password', __('This account requires password login before passkey verification.', 'acemedia-login-block'), ['status' => 403]);
}
$passkeys = $this->get_passkeys($user->ID);
if (empty($passkeys)) {
return new \WP_Error('no_passkeys', __('No passkeys registered for this account.', 'acemedia-login-block'), ['status' => 404]);
}
foreach ($passkeys as $passkey) {
if (!empty($passkey['id'])) {
$allow_list[] = ByteBuffer::fromBase64Url($passkey['id']);
}
}
}
$webauthn = $this->get_webauthn();
$allow_credentials = empty($allow_list) ? null : $allow_list;
$getArgs = $webauthn->getGetArgs($allow_credentials, 60, true, true, true, true, true, $context === 'second_factor' ? 'preferred' : 'required');
$state = $this->generate_state();
$this->store_state(self::TRANSIENT_LOGIN, $state, [
'user_id' => $user ? $user->ID : 0,
'challenge' => $this->buffer_to_base64url($webauthn->getChallenge()),
'context' => $context,
'discoverable' => $discoverable,
], 10 * MINUTE_IN_SECONDS);
return rest_ensure_response([
'options' => $getArgs,
'state' => $state,
]);
}
public function finish_login($request) {
if (!$this->is_enabled()) {
return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]);
}
$state = sanitize_text_field($request->get_param('state'));
$state_data = self::take_once(self::TRANSIENT_LOGIN, $state);
if (!$state_data) {
return new \WP_Error('invalid_state', __('Login session expired.', 'acemedia-login-block'), ['status' => 400]);
}
$credential = $request->get_param('credential');
if (!is_array($credential) || empty($credential['response'])) {
return new \WP_Error('invalid_credential', __('Invalid credential payload.', 'acemedia-login-block'), ['status' => 400]);
}
$user_id = (int) ($state_data['user_id'] ?? 0);
$user = $user_id ? get_user_by('id', $user_id) : null;
$credential_id = sanitize_text_field($credential['id'] ?? '');
if (!$credential_id) {
return new \WP_Error('invalid_credential', __('Invalid credential payload.', 'acemedia-login-block'), ['status' => 400]);
}
$passkey = null;
if (!$user) {
$match = $this->find_user_by_credential_id($credential_id);
if ($match) {
$user = $match['user'];
$user_id = (int) $user->ID;
$passkey = $match['passkey'];
}
} else {
$passkey = $this->find_passkey($user_id, $credential_id);
}
if (!$user || !$passkey) {
do_action('wp_login_failed', $user ? $user->user_login : '');
return new \WP_Error('unknown_credential', __('Passkey not recognized.', 'acemedia-login-block'), ['status' => 403]);
}
if ($this->is_locked_out($user_id)) {
return new \WP_Error('locked_out', __('Account is temporarily locked.', 'acemedia-login-block'), ['status' => 403]);
}
$clientDataJSON = $this->base64url_decode($credential['response']['clientDataJSON'] ?? '');
$authenticatorData = $this->base64url_decode($credential['response']['authenticatorData'] ?? '');
$signature = $this->base64url_decode($credential['response']['signature'] ?? '');
$challenge = $this->base64url_decode($state_data['challenge']);
if (!$clientDataJSON || !$authenticatorData || !$signature) {
do_action('wp_login_failed', $user->user_login);
return new \WP_Error('invalid_payload', __('Invalid login payload.', 'acemedia-login-block'), ['status' => 400]);
}
try {
$webauthn = $this->get_webauthn();
$webauthn->processGet(
$clientDataJSON,
$authenticatorData,
$signature,
$passkey['publicKey'],
$challenge,
isset($passkey['counter']) ? (int) $passkey['counter'] : 0,
($state_data['context'] ?? '') !== 'second_factor',
true
);
} catch (\Exception $e) {
do_action('wp_login_failed', $user->user_login);
return new \WP_Error('login_failed', $e->getMessage(), ['status' => 403]);
}
$passkey['counter'] = $this->extract_counter(null, $webauthn);
$passkey['last_used'] = time();
$this->update_passkey($user_id, $passkey);
$this->delete_state(self::TRANSIENT_LOGIN, $state);
if (($state_data['context'] ?? '') === 'second_factor') {
$token = $this->generate_state(24);
$this->store_state(self::TRANSIENT_2FA, $token, [
'user_id' => $user_id,
], 5 * MINUTE_IN_SECONDS);
return rest_ensure_response([
'success' => true,
'token' => $token,
]);
}
if (($state_data['context'] ?? '') === 'password_factor') {
$token = $this->generate_state(24);
$this->store_state(self::TRANSIENT_PASSWORD_FACTOR, $token, [
'user_id' => $user_id,
], 5 * MINUTE_IN_SECONDS);
return rest_ensure_response([
'success' => true,
'token' => $token,
'login' => $user->user_login,
]);
}
if ($this->user_requires_2fa($user) && !$this->user_allows_passwordless($user)) {
return new \WP_Error('passkey_requires_password', __('This account requires password login before passkey verification.', 'acemedia-login-block'), ['status' => 403]);
}
// Only an asserted, UV-verified passkey reaches this internal bridge.
// Keep the normal authenticate chain (including multisite spam checks),
// session creation and wp_login observers available to integrations.
$provide_verified_user = static function ($candidate) use ($user) {
return is_wp_error($candidate) ? $candidate : apply_filters('wp_authenticate_user', $user, '');
};
add_filter('authenticate', $provide_verified_user, 15, 1);
self::$verified_passwordless_user = (int) $user_id;
try {
$signed_in = wp_signon(['user_login' => $user->user_login, 'user_password' => '', 'remember' => false], is_ssl());
} finally {
self::$verified_passwordless_user = 0;
remove_filter('authenticate', $provide_verified_user, 15);
}
if (is_wp_error($signed_in)) {
$code = $signed_in->get_error_code();
$data = $signed_in->get_error_data($code);
if (!is_array($data) || !isset($data['status'])) {
$signed_in->add_data(array_merge(is_array($data) ? $data : [], ['status' => 403]), $code);
}
return $signed_in;
}
wp_set_current_user($signed_in->ID);
return rest_ensure_response([
'success' => true,
'redirect' => $this->get_redirect_url($user),
]);
}
public function remove_passkey($request) {
if (!$this->is_enabled()) {
return new \WP_Error('passkeys_disabled', __('Passkeys are disabled.', 'acemedia-login-block'), ['status' => 403]);
}
$user = wp_get_current_user();
if (!$user || !$user->ID) {
return new \WP_Error('not_logged_in', __('Not logged in.', 'acemedia-login-block'), ['status' => 401]);
}
$id = sanitize_text_field($request->get_param('id'));
if (!$id) {
return new \WP_Error('missing_id', __('Missing passkey id.', 'acemedia-login-block'), ['status' => 400]);
}
$passkeys = $this->get_passkeys($user->ID);
$updated = [];
$removed = false;
foreach ($passkeys as $passkey) {
if (!isset($passkey['id']) || $passkey['id'] !== $id) {
$updated[] = $passkey;
} else {
$removed = true;
}
}
if (!$removed) {
return new \WP_Error('not_found', __('Passkey not found.', 'acemedia-login-block'), ['status' => 404]);
}
update_user_meta($user->ID, self::META_KEY, $updated);
Logging::log_event($user->ID, 'passkey_removed', [
'id' => $id,
], true);
return rest_ensure_response(['success' => true]);
}
public static function verify_2fa_token($user_id, $token) {
$token = sanitize_text_field($token);
if (!$token) {
return false;
}
$data = self::take_once(self::TRANSIENT_2FA, $token);
if (!$data || (int) ($data['user_id'] ?? 0) !== (int) $user_id) {
return false;
}
delete_transient(self::TRANSIENT_2FA . $token);
return true;
}
/** Read-only first-factor inspection for the factor-selection request. */
public static function inspect_password_token($token, $username) {
$data = get_transient(self::TRANSIENT_PASSWORD_FACTOR . sanitize_text_field($token));
$user = !empty($data['user_id']) ? get_user_by('id', (int) $data['user_id']) : false;
if (!$user || (string) $username === '') {
return false;
}
if (!hash_equals((string) $user->user_login, (string) $username)
&& !hash_equals((string) $user->user_email, (string) $username)) {
return false;
}
return $user;
}
public function authenticate_with_passkey_token($user, $username, $password) {
if (is_wp_error($user)) {
return $user;
}
$token = isset($_POST['acemedia_passkey_password_token'])
? sanitize_text_field(wp_unslash($_POST['acemedia_passkey_password_token']))
: '';
if (!$token) {
return $user;
}
$token_data = self::take_once(self::TRANSIENT_PASSWORD_FACTOR, $token);
if (!$token_data || empty($token_data['user_id'])) {
return new \WP_Error('passkey_password_token_invalid', __('Passkey login session expired. Please try again.', 'acemedia-login-block'));
}
$target_user = get_user_by('id', (int) $token_data['user_id']);
if (!$target_user) {
return new \WP_Error('passkey_password_user_missing', __('Passkey login failed for this account. Please try again.', 'acemedia-login-block'));
}
if (!empty($username)) {
$normalized_username = sanitize_text_field($username);
$matches_login = hash_equals((string) $target_user->user_login, (string) $normalized_username);
$matches_email = is_email($normalized_username) && hash_equals((string) $target_user->user_email, (string) $normalized_username);
if (!$matches_login && !$matches_email) {
return new \WP_Error('passkey_password_username_mismatch', __('Passkey matched a different account than the provided username.', 'acemedia-login-block'));
}
}
delete_transient(self::TRANSIENT_PASSWORD_FACTOR . $token);
return apply_filters('wp_authenticate_user', $target_user, $password);
}
private function is_enabled() {
if (!self::runtime_available()) {
return false;
}
return (bool) get_option('acemedia_passkeys_enabled', true);
}
public function require_logged_in() {
return is_user_logged_in();
}
private function get_webauthn() {
return new WebAuthn(
get_bloginfo('name'),
$this->get_rp_id(),
null,
true
);
}
private function get_rp_id() {
$override = get_option('acemedia_passkeys_rp_id', '');
if (!empty($override)) {
return apply_filters('acemedia_passkeys_rp_id', $override);
}
$host = wp_parse_url(home_url(), PHP_URL_HOST);
if (defined('COOKIE_DOMAIN') && COOKIE_DOMAIN) {
$host = ltrim(COOKIE_DOMAIN, '.');
} elseif (!$host && isset($_SERVER['HTTP_HOST'])) {
$host = sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST']));
}
$host = $host ?: 'localhost';
return apply_filters('acemedia_passkeys_rp_id', $host);
}
private function get_attestation_preference() {
$value = sanitize_text_field(get_option('acemedia_passkeys_attestation', 'preferred'));
$allowed = ['preferred', 'none', 'indirect', 'direct', 'enterprise'];
if (!in_array($value, $allowed, true)) {
return 'preferred';
}
return $value;
}
private function get_passkeys($user_id) {
$passkeys = get_user_meta($user_id, self::META_KEY, true);
return is_array($passkeys) ? $passkeys : [];
}
private function save_passkey($user_id, array $data) {
$passkeys = $this->get_passkeys($user_id);
$existing = false;
foreach ($passkeys as $index => $passkey) {
if (!empty($passkey['id']) && $passkey['id'] === $data['id']) {
$passkeys[$index] = array_merge($passkey, $data);
$existing = true;
break;
}
}
if (!$existing) {
$passkeys[] = $data;
}
update_user_meta($user_id, self::META_KEY, $passkeys);
}
private function update_passkey($user_id, array $data) {
$this->save_passkey($user_id, $data);
}
private function find_passkey($user_id, $credential_id) {
$passkeys = $this->get_passkeys($user_id);
foreach ($passkeys as $passkey) {
if (!empty($passkey['id']) && hash_equals($passkey['id'], $credential_id)) {
return $passkey;
}
}
return null;
}
private function find_user_by_credential_id($credential_id) {
$users = get_users([
'meta_key' => self::META_KEY,
'fields' => ['ID'],
]);
foreach ($users as $user_obj) {
$passkeys = $this->get_passkeys($user_obj->ID);
foreach ($passkeys as $passkey) {
if (!empty($passkey['id']) && hash_equals($passkey['id'], $credential_id)) {
$user = get_user_by('id', $user_obj->ID);
if ($user) {
return [
'user' => $user,
'passkey' => $passkey,
];
}
}
}
}
return null;
}
private function user_requires_2fa($user) {
if (get_user_meta($user->ID, '_acemedia_2fa_enabled', true)) {
return true;
}
foreach ($user->roles as $role) {
if (
get_option("acemedia_2fa_enabled_{$role}", false)
|| get_option("acemedia_passkey_2fa_required_{$role}", false)
) {
return true;
}
}
return false;
}
private function user_allows_passwordless($user) {
foreach ($user->roles as $role) {
if (
get_option("acemedia_passkey_passwordless_{$role}", false)
|| get_option("acemedia_passkey_2fa_required_{$role}", false)
) {
return true;
}
}
return false;
}
private function get_redirect_url($user) {
$redirect_url = admin_url();
foreach ($user->roles as $role) {
$role_redirect_key = "acemedia_login_block_redirect_{$role}";
$role_redirect_url = get_option($role_redirect_key);
if (!empty($role_redirect_url)) {
$redirect_url = esc_url($role_redirect_url);
break;
}
}
return wp_validate_redirect(apply_filters('login_redirect', $redirect_url, '', $user), admin_url());
}
private function get_user_from_login($login) {
$login = sanitize_text_field($login);
$user = get_user_by('login', $login);
if (!$user && is_email($login)) {
$user = get_user_by('email', $login);
}
return $user ?: null;
}
private function generate_state($length = 16) {
return bin2hex(random_bytes($length));
}
/** Claim a transient once across workers, including persistent object caches. */
private static function take_once($prefix, $token) {
if (!is_string($token) || $token === '') {
return false;
}
$key = 'acemedia_passkey_claim_' . hash('sha256', $prefix . $token);
$previous = get_option($key, false);
// All issued states live at most ten minutes. Never reclaim a lock while
// its original state can still be valid, even if a worker was suspended.
if (is_string($previous) && (int) $previous < time() - 11 * MINUTE_IN_SECONDS) {
self::release_claim($key, $previous);
}
$owner = time() . ':' . bin2hex(random_bytes(16));
global $wpdb;
// add_option() can upsert on a concurrent cache miss. A plain unique-key
// INSERT IGNORE is required for exclusive ownership across workers.
$claimed = $wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')",
$key, $owner
));
wp_cache_delete($key, 'options');
if ($claimed !== 1) {
return false;
}
if (!wp_next_scheduled('acemedia_passkey_claim_cleanup')) {
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'acemedia_passkey_claim_cleanup');
}
try {
$data = get_transient($prefix . $token);
delete_transient($prefix . $token);
return $data;
} finally {
self::release_claim($key, $owner);
}
}
private static function release_claim($key, $owner) {
global $wpdb;
// Conditional deletion prevents an old worker releasing another claim.
$wpdb->delete($wpdb->options, ['option_name' => $key, 'option_value' => $owner], ['%s', '%s']);
wp_cache_delete($key, 'options');
}
public static function cleanup_expired_claims() {
global $wpdb;
$like = $wpdb->esc_like('acemedia_passkey_claim_') . '%';
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $like
));
$pending = false;
foreach ($rows as $row) {
if ((int) $row->option_value < time() - 11 * MINUTE_IN_SECONDS) {
self::release_claim($row->option_name, $row->option_value);
} else {
$pending = true;
}
}
if ($pending && !wp_next_scheduled('acemedia_passkey_claim_cleanup')) {
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'acemedia_passkey_claim_cleanup');
}
}
private function store_state($prefix, $state, array $data, $ttl) {
set_transient($prefix . $state, $data, $ttl);
}
private function get_state($prefix, $state) {
return get_transient($prefix . $state);
}
private function delete_state($prefix, $state) {
delete_transient($prefix . $state);
}
private function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function base64url_decode($data) {
$remainder = strlen($data) % 4;
if ($remainder) {
$data .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($data, '-_', '+/'));
return $decoded === false ? '' : $decoded;
}
private function buffer_to_base64url($buffer) {
if ($buffer instanceof ByteBuffer) {
if (method_exists($buffer, 'getBase64Url')) {
return $buffer->getBase64Url();
}
if (method_exists($buffer, 'getBinaryString')) {
return $this->base64url_encode($buffer->getBinaryString());
}
}
if (is_string($buffer)) {
return $this->base64url_encode($buffer);
}
return '';
}
private function extract_credential_id($createData) {
$candidates = ['credentialId', 'credentialID', 'credId', 'id'];
foreach ($candidates as $candidate) {
if (isset($createData->$candidate)) {
return $this->buffer_to_base64url($createData->$candidate);
}
}
foreach (get_object_vars($createData) as $value) {
if ($value instanceof ByteBuffer) {
return $this->buffer_to_base64url($value);
}
}
return '';
}
private function extract_public_key($createData) {
$candidates = ['credentialPublicKey', 'publicKey', 'publicKeyPem', 'credentialPublicKeyPem'];
foreach ($candidates as $candidate) {
if (isset($createData->$candidate) && is_string($createData->$candidate)) {
return $createData->$candidate;
}
}
foreach (get_object_vars($createData) as $value) {
if (is_string($value) && strpos($value, 'BEGIN PUBLIC KEY') !== false) {
return $value;
}
}
return '';
}
private function extract_counter($createData, $webauthn) {
if ($createData) {
$candidates = ['signatureCounter', 'signCount', 'counter'];
foreach ($candidates as $candidate) {
if (isset($createData->$candidate)) {
return (int) $createData->$candidate;
}
}
}
$counter = $webauthn ? $webauthn->getSignatureCounter() : null;
return $counter === null ? 0 : (int) $counter;
}
private function is_locked_out($user_id) {
$locked_until = (int) get_user_meta($user_id, '_acemedia_login_locked_until', true);
return $locked_until && time() < $locked_until;
}
}
new Passkeys();