PluginProbe
DoLogin Security / trunk
DoLogin Security vtrunk
5.0.10 4.8.3 trunk 1.0 1.1 1.1.1 1.2 1.2.1 1.2.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 1.6 All 64 releases
dologin / src / klsso.cls.php

klsso.cls.php in DoLogin Security trunk, at src/klsso.cls.php

952 lines 34.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * KeyLockr SSO integration.
4 *
5 * @since 4.5
6 * @package dologin
7 */
8
9 namespace dologin;
10
11 defined( 'WPINC' ) || exit;
12
13 class KLSso extends Instance {
14 use KLSso_Keys;
15 use KLSso_Protocol;
16 use KLSso_Repair;
17 use KLSso_State;
18 use KLSso_UI;
19
20 const META_SAFE_ID = 'dologin_kl_safe_id';
21 const META_NICKNAME = 'dologin_kl_nickname';
22 const META_APP_HASH = 'dologin_kl_app_hash';
23 const META_SITE_KEY_FP = 'dologin_kl_site_key_fingerprint';
24 const META_APP_TAG = 'dologin_kl_app_tag';
25 const TRANSIENT_PREFIX = 'dologin_kl_sso_';
26 const CLOCK_CACHE = 'dologin_kl_sso_clock_ok';
27 const SERVER_KEYS_CACHE = 'dologin_kl_sso_server_keys';
28 const SITE_KEYS_OPTION = 'dologin.kl_sso_site_keys';
29 const SESSION_TTL = 600;
30 const START_WINDOW = 60;
31 const START_LIMIT = 6;
32 const FRAME_LIMIT = 64;
33 const FRAME_MAX_BYTES = 65536;
34 const FRAME_IP_LIMIT = 120;
35 const CLOCK_PAST_LIMIT = 600;
36 const CLOCK_FUTURE_LIMIT = 180;
37 const REPAIR_REQUIRED_CODE = 4702;
38 const APP_AUTH_TERMINAL_CODE = 4703;
39 const AUTH_DENIED_CODE = 4704;
40 const LOGIN_IDENTITY_CODE = 4705;
41 const APPDATA_CAS_RETRY_LIMIT = 1;
42 const API_BASE = 'https://api.keylockr.app/v3';
43 const WS_URL = 'wss://api.keylockr.app/v3/ws';
44 const WWW_URL = 'https://keylockr.app';
45 const DEVELOPER_ADD_URL = 'https://my.keylockr.app/developer_sso_add';
46 const QR_SCHEME_TEMPLATE = 'keylockr://sso?tmp_id=%s';
47
48 /**
49 * Register hooks.
50 */
51 public function init() {
52 add_action( 'show_user_profile', array( $this, 'profile_form' ) );
53 }
54
55 /**
56 * Is SSO configured enough to show login/bind widgets.
57 */
58 public static function enabled() {
59 return (bool) Conf::val( 'kl_sso' ) && self::app_tag();
60 }
61
62 /**
63 * Whether the saved policy restricts interactive login to KeyLockr QR.
64 */
65 public static function force_enabled() {
66 return (bool) Conf::val( 'kl_sso_force' );
67 }
68
69 /**
70 * Is service configuration present.
71 */
72 public static function configured() {
73 return (bool) self::app_tag();
74 }
75
76 /**
77 * Runtime requirements for KeyLockr SSO.
78 */
79 public static function requirements() {
80 $errors = array();
81 if ( ! self::sodium_ready() ) {
82 $errors[] = __( 'Sodium cryptography support is required.', 'dologin' );
83 }
84 if ( ! file_exists( DOLOGIN_DIR . 'qilu/npm/qrcode-generator/qrcode.js' ) ) {
85 $errors[] = __( 'Bundled QR code generator is missing.', 'dologin' );
86 }
87 return $errors;
88 }
89
90 /**
91 * Return the effective KeyLockr app_tag.
92 *
93 * Keep the existing setting key to avoid a local migration, but never send its value as svc_id.
94 */
95 public static function app_tag() {
96 return trim( (string) Conf::val( 'kl_sso_svc_id' ) );
97 }
98
99 /**
100 * API base URL.
101 */
102 public static function api_base() {
103 return self::API_BASE;
104 }
105
106 /**
107 * WebSocket URL.
108 */
109 public static function ws_url() {
110 return self::WS_URL;
111 }
112
113 /**
114 * Build the current AppData handshake payload.
115 */
116 private function handshake_body( $sign_pk, $enc_pk, $name, $app_tag = '' ) {
117 $app_tag = $app_tag ? $app_tag : self::app_tag();
118 return KLSso_MsgPack::pack(
119 array(
120 'app_data' => true,
121 'app_tag' => $app_tag,
122 'enc_pk' => KLSso_MsgPack::bin( $enc_pk ),
123 'name' => $name,
124 'sign_pk' => KLSso_MsgPack::bin( $sign_pk ),
125 )
126 );
127 }
128
129 /**
130 * Per-user appdata hash. Generated once and stored in user meta.
131 */
132 private static function user_app_hash( $uid ) {
133 $uid = (int) $uid;
134 if ( $uid <= 0 ) {
135 return '';
136 }
137
138 $hash = trim( (string) get_user_meta( $uid, self::META_APP_HASH, true ) );
139 if ( $hash ) {
140 return $hash;
141 }
142
143 $hash = hash_hmac( 'sha256', 'dologin-kl-user|' . home_url() . '|' . $uid, wp_salt( 'auth' ) );
144 update_user_meta( $uid, self::META_APP_HASH, $hash );
145 return $hash;
146 }
147
148 /**
149 * Current user's bind status for settings UI.
150 */
151 public static function current_user_status() {
152 $uid = get_current_user_id();
153 if ( ! $uid ) {
154 return array(
155 'bound' => false,
156 );
157 }
158
159 $safe_id = get_user_meta( $uid, self::META_SAFE_ID, true );
160 $app_tag = trim( (string) get_user_meta( $uid, self::META_APP_TAG, true ) );
161 $current_tag = self::app_tag();
162 return array(
163 'bound' => (bool) $safe_id,
164 'key_current' => (bool) $safe_id && self::binding_uses_current_connection( $uid ),
165 'app_tag_changed' => (bool) $safe_id && '' !== $app_tag && '' !== $current_tag && ! hash_equals( $current_tag, $app_tag ),
166 'safe_id' => $safe_id,
167 'nickname' => get_user_meta( $uid, self::META_NICKNAME, true ),
168 );
169 }
170
171 /**
172 * Whether this user's binding has been verified with the current KeyLockr connection identity.
173 */
174 private static function binding_uses_current_connection( $uid ) {
175 $fingerprint = trim( (string) get_user_meta( (int) $uid, self::META_SITE_KEY_FP, true ) );
176 $app_tag = trim( (string) get_user_meta( (int) $uid, self::META_APP_TAG, true ) );
177 $current_tag = self::app_tag();
178 return '' !== $fingerprint
179 && '' !== $app_tag
180 && '' !== $current_tag
181 && hash_equals( $current_tag, $app_tag )
182 && self::site_key_fingerprint_matches( $fingerprint );
183 }
184
185 /**
186 * Whether the current administrator has a complete binding for force-mode activation.
187 */
188 public static function force_ready( $app_tag = '' ) {
189 $uid = get_current_user_id();
190 $app_tag = trim( (string) $app_tag );
191 $capability = apply_filters( 'dologin_admin_menu_access', 'manage_options' );
192 if ( ! $uid || ! $app_tag || ! get_userdata( $uid ) || ! user_can( $uid, $capability ) ) {
193 return false;
194 }
195 $safe_id = trim( (string) get_user_meta( $uid, self::META_SAFE_ID, true ) );
196 $app_hash = trim( (string) get_user_meta( $uid, self::META_APP_HASH, true ) );
197 $stored_tag = trim( (string) get_user_meta( $uid, self::META_APP_TAG, true ) );
198 $fingerprint = trim( (string) get_user_meta( $uid, self::META_SITE_KEY_FP, true ) );
199 return '' !== $safe_id
200 && '' !== $app_hash
201 && '' !== $stored_tag
202 && '' !== $fingerprint
203 && hash_equals( $app_tag, $stored_tag )
204 && self::site_key_fingerprint_matches( $fingerprint );
205 }
206
207 /**
208 * Unlink KeyLockr from the current WordPress user.
209 *
210 * @since 4.6.5
211 */
212 public function unbind() {
213 $uid = get_current_user_id();
214 if ( ! $uid ) {
215 return REST::err( __( 'You need to login before unlinking KeyLockr SSO.', 'dologin' ) );
216 }
217 if ( self::force_enabled() ) {
218 return REST::err( __( 'Disable Force KeyLockr SSO before unlinking this account.', 'dologin' ) );
219 }
220
221 $meta_keys = array(
222 self::META_SAFE_ID,
223 self::META_NICKNAME,
224 self::META_APP_HASH,
225 self::META_SITE_KEY_FP,
226 self::META_APP_TAG,
227 'dologin_kl_sso_client_id',
228 'dologin_kl_bound_at',
229 );
230 foreach ( $meta_keys as $meta_key ) {
231 delete_user_meta( $uid, $meta_key );
232 }
233
234 return REST::ok(
235 array(
236 'status' => 'done',
237 'message' => __( 'KeyLockr SSO unlinked successfully.', 'dologin' ),
238 )
239 );
240 }
241
242 /**
243 * Start a KeyLockr SSO session.
244 */
245 public function start( $request ) {
246 $raw_mode = $request->get_param( 'mode' );
247 $mode = is_string( $raw_mode ) ? sanitize_key( $raw_mode ) : '';
248 if ( ! in_array( $mode, array( 'login', 'bind', 'verify', 'repair' ), true ) ) {
249 return REST::err( __( 'Invalid KeyLockr SSO mode.', 'dologin' ) );
250 }
251
252 if ( 'login' === $mode && ! self::enabled() ) {
253 return REST::err( __( 'KeyLockr SSO is not configured.', 'dologin' ) );
254 }
255
256 if ( in_array( $mode, array( 'bind', 'verify', 'repair' ), true ) && ! self::configured() ) {
257 return REST::err( __( 'KeyLockr App Tag is not configured.', 'dologin' ) );
258 }
259
260 $requirements = self::requirements();
261 if ( $requirements ) {
262 return REST::err( implode( ' ', $requirements ) );
263 }
264
265 if ( in_array( $mode, array( 'bind', 'verify', 'repair' ), true ) && ! is_user_logged_in() ) {
266 return REST::err( 'verify' === $mode
267 ? __( 'You need to login before verifying the KeyLockr connection.', 'dologin' )
268 : __( 'You need to login before linking KeyLockr SSO.', 'dologin' ) );
269 }
270
271 if ( in_array( $mode, array( 'verify', 'repair' ), true ) && empty( self::current_user_status()['bound'] ) ) {
272 return REST::err( __( 'Link this WordPress account with KeyLockr SSO before verifying the connection.', 'dologin' ) );
273 }
274
275 if ( 'login' === $mode ) {
276 if ( $this->cls( 'Auth' )->is_ip_denied() ) {
277 return REST::err( __( 'This IP is not allowed to login.', 'dologin' ) );
278 }
279 if ( $this->cls( 'Auth' )->is_rate_limited() ) {
280 return REST::err( Lang::msg( 'max_retries_hit' ) );
281 }
282 }
283
284 if ( $this->start_rate_limited() ) {
285 return REST::err( __( 'Too many KeyLockr SSO session requests. Please try later.', 'dologin' ) );
286 }
287
288 $clock = $this->check_server_clock();
289 if ( is_wp_error( $clock ) ) {
290 return REST::err( $clock->get_error_message() );
291 }
292
293 $keys = $this->server_keys();
294 if ( is_wp_error( $keys ) ) {
295 return REST::err( $keys->get_error_message() );
296 }
297
298 try {
299 $state_id = bin2hex( random_bytes( 16 ) );
300 } catch ( \Exception $ex ) {
301 return REST::err( __( 'Failed to create KeyLockr SSO session.', 'dologin' ) );
302 }
303
304 $site_keys = self::site_keys();
305 if ( is_wp_error( $site_keys ) ) {
306 return REST::err( $site_keys->get_error_message() );
307 }
308 $sign_kp = $site_keys['sign_kp'];
309 $box_kp = $site_keys['box_kp'];
310 $sign_pk = sodium_crypto_sign_publickey( $sign_kp );
311 $sign_sk = sodium_crypto_sign_secretkey( $sign_kp );
312 $enc_pk = sodium_crypto_box_publickey( $box_kp );
313 $enc_sk = sodium_crypto_box_secretkey( $box_kp );
314
315 $app_tag = self::app_tag();
316 $body = $this->handshake_body( $sign_pk, $enc_pk, get_bloginfo( 'name' ) . ' DoLogin', $app_tag );
317
318 $res = wp_safe_remote_post(
319 self::api_base() . '/handshake',
320 array(
321 'timeout' => 15,
322 'redirection' => 0,
323 'limit_response_size' => self::FRAME_MAX_BYTES,
324 'sslverify' => true,
325 'headers' => array(
326 'Accept' => 'application/octet-stream',
327 'Content-Type' => 'application/octet-stream',
328 ),
329 'body' => $body,
330 )
331 );
332 if ( is_wp_error( $res ) ) {
333 return REST::err( $res->get_error_message() );
334 }
335 if ( (int) wp_remote_retrieve_response_code( $res ) < 200 || (int) wp_remote_retrieve_response_code( $res ) >= 300 ) {
336 return REST::err( __( 'KeyLockr handshake returned an invalid response status.', 'dologin' ) );
337 }
338
339 try {
340 $decoded = KLSso_MsgPack::unpack( wp_remote_retrieve_body( $res ) );
341 } catch ( \Exception $ex ) {
342 return REST::err( __( 'Failed to decode KeyLockr handshake response.', 'dologin' ) );
343 }
344 if ( ! is_array( $decoded ) || ! isset( $decoded['_res'] ) || 'ok' !== $decoded['_res'] ) {
345 return REST::err( is_array( $decoded ) ? $this->handshake_error_message( $decoded ) : __( 'KeyLockr handshake failed.', 'dologin' ) );
346 }
347
348 $tmp_id = isset( $decoded['tmp_id'] ) && is_string( $decoded['tmp_id'] ) ? $decoded['tmp_id'] : '';
349 if ( ! $this->valid_tmp_id( $tmp_id ) ) {
350 return REST::err( __( 'KeyLockr handshake did not return tmp_id.', 'dologin' ) );
351 }
352
353 $state = array(
354 'mode' => $mode,
355 'phase' => 'tmp_auth',
356 'user_id' => get_current_user_id(),
357 'id' => 'tmp.' . $tmp_id,
358 'tmp_id' => $tmp_id,
359 'app_tag' => $app_tag,
360 'site_key_fingerprint' => self::site_key_fingerprint_from_keys( $site_keys ),
361 'sign_sk' => base64_encode( $sign_sk ),
362 'enc_sk' => base64_encode( $enc_sk ),
363 'server_enc_pk' => base64_encode( $keys['enc_pk'] ),
364 'server_sign_pk' => base64_encode( $keys['sign_pk'] ),
365 );
366 if ( ! $this->save_state( $state_id, $state ) ) {
367 return REST::err( __( 'Failed to store KeyLockr SSO session.', 'dologin' ) );
368 }
369
370 return REST::ok(
371 array(
372 'state' => $state_id,
373 'qr' => sprintf( self::QR_SCHEME_TEMPLATE, rawurlencode( $tmp_id ) ),
374 'ws_url' => $this->ws_url_for( 'tmp.' . $tmp_id, $sign_sk ),
375 )
376 );
377 }
378
379 /**
380 * Process a KeyLockr WebSocket frame forwarded by the browser.
381 */
382 public function frame( $request ) {
383 $raw_state = $request->get_param( 'state' );
384 $raw_frame = $request->get_param( 'frame' );
385 $state_id = is_string( $raw_state ) ? sanitize_text_field( $raw_state ) : '';
386 $frame = is_string( $raw_frame ) ? $raw_frame : '';
387 if ( $this->request_rate_limited( 'frame', self::FRAME_IP_LIMIT, self::START_WINDOW ) ) {
388 return REST::err( __( 'Too many KeyLockr SSO frame requests. Please try later.', 'dologin' ) );
389 }
390 if ( ! preg_match( '/^[a-f0-9]{32}$/', $state_id ) ) {
391 return REST::err( __( 'KeyLockr SSO session expired.', 'dologin' ) );
392 }
393 $lock_owner = $this->acquire_state_lock( $state_id );
394 if ( ! $lock_owner ) {
395 return REST::err( __( 'KeyLockr SSO session is busy. Please retry.', 'dologin' ) );
396 }
397
398 try {
399 return $this->process_frame( $state_id, $frame );
400 } finally {
401 $this->release_state_lock( $state_id, $lock_owner );
402 }
403 }
404
405 /**
406 * Process one forwarded frame while holding the state lock.
407 */
408 private function process_frame( $state_id, $frame ) {
409 $state = $this->load_state( $state_id );
410 if ( ! $state ) {
411 return REST::err( __( 'KeyLockr SSO session expired.', 'dologin' ) );
412 }
413 if ( empty( $state['site_key_fingerprint'] ) || ! self::site_key_fingerprint_matches( $state['site_key_fingerprint'] ) ) {
414 $this->delete_state( $state_id );
415 return REST::err( __( 'KeyLockr site keys changed. Start a new session.', 'dologin' ) );
416 }
417 if ( empty( $state['app_tag'] ) || ! hash_equals( self::app_tag(), (string) $state['app_tag'] ) ) {
418 $this->delete_state( $state_id );
419 return REST::err( __( 'KeyLockr App Tag changed. Start a new session.', 'dologin' ) );
420 }
421
422 if ( isset( $state['mode'] )
423 && in_array( $state['mode'], array( 'bind', 'verify', 'repair' ), true )
424 && (int) get_current_user_id() !== (int) $state['user_id'] ) {
425 return REST::err( __( 'KeyLockr SSO account session does not match current user.', 'dologin' ) );
426 }
427 $state['frame_count'] = isset( $state['frame_count'] ) ? (int) $state['frame_count'] + 1 : 1;
428 if ( $state['frame_count'] > self::FRAME_LIMIT || strlen( $frame ) > ( self::FRAME_MAX_BYTES * 2 ) ) {
429 $this->delete_state( $state_id );
430 return REST::err( __( 'KeyLockr SSO frame limit exceeded.', 'dologin' ) );
431 }
432
433 $bytes = base64_decode( $frame, true );
434 if ( false === $bytes || strlen( $bytes ) > self::FRAME_MAX_BYTES ) {
435 if ( ! $this->save_state( $state_id, $state ) ) {
436 $this->delete_state( $state_id );
437 }
438 return REST::err( __( 'Invalid KeyLockr SSO frame.', 'dologin' ) );
439 }
440 if ( ! $this->remember_frame( $state, $bytes ) ) {
441 if ( ! $this->save_state( $state_id, $state ) ) {
442 $this->delete_state( $state_id );
443 }
444 return REST::err( __( 'KeyLockr SSO frame was already processed.', 'dologin' ) );
445 }
446
447 try {
448 list( $action, $body ) = $this->open_kps( $state, $bytes );
449 $res = $this->handle_kps_action( $state, $action, $body );
450 } catch ( \Exception $ex ) {
451 if ( $this->is_terminal_kps_error( $ex ) ) {
452 $this->delete_state( $state_id );
453 } elseif ( ! $this->save_state( $state_id, $state ) ) {
454 $this->delete_state( $state_id );
455 return REST::err( __( 'Failed to store KeyLockr SSO session.', 'dologin' ) );
456 }
457 $this->fail_login_if_needed( $state, $ex );
458 $error = REST::err( $ex->getMessage() );
459 if ( self::REPAIR_REQUIRED_CODE === $ex->getCode() && isset( $state['mode'] ) && 'verify' === $state['mode'] ) {
460 $error['repair'] = true;
461 }
462 return $error;
463 } catch ( \Throwable $ex ) {
464 if ( ! $this->save_state( $state_id, $state ) ) {
465 $this->delete_state( $state_id );
466 return REST::err( __( 'Failed to store KeyLockr SSO session.', 'dologin' ) );
467 }
468 return REST::err( __( 'Invalid KeyLockr SSO frame.', 'dologin' ) );
469 }
470
471 if ( ! empty( $res['_delete_state'] ) ) {
472 $this->delete_state( $state_id );
473 unset( $res['_delete_state'] );
474 } elseif ( ! $this->save_state( $state_id, $state ) ) {
475 $this->delete_state( $state_id );
476 return REST::err( __( 'Failed to store KeyLockr SSO session.', 'dologin' ) );
477 }
478 return REST::ok( $res );
479 }
480
481 /**
482 * Handle decrypted KPS actions.
483 */
484 private function handle_kps_action( &$state, $action, $body ) {
485 if ( ! is_array( $body ) ) {
486 throw new \Exception( __( 'Invalid KeyLockr SSO response.', 'dologin' ) );
487 }
488 $this->assert_kps_action_phase( $state, $action );
489
490 if ( ! isset( $body['_res'] ) || 'ok' !== $body['_res'] ) {
491 $code = isset( $body['code'] ) && is_scalar( $body['code'] ) ? sanitize_text_field( (string) $body['code'] ) : 'unknown_error';
492 if ( isset( $body['_res'] ) && 'err' === $body['_res'] ) {
493 if ( 'app_auth_result' === $action && $this->authorization_denied_code( $code ) ) {
494 throw $this->app_auth_terminal_error( sprintf( __( 'KeyLockr authorization failed: %s', 'dologin' ), $code ), true );
495 }
496 if ( 'app_file_not_found' === $code ) {
497 throw $this->appdata_disabled_error();
498 }
499 if ( $this->appdata_write_mode( $state ) && 'app_set_data' === $action && 'file_ver_conflict' === $code ) {
500 $res = $this->retry_appdata_write( $state );
501 $state['phase'] = 'app_read';
502 return $res;
503 }
504 if ( $this->appdata_write_mode( $state ) && in_array( $action, array( 'app_set_data', 'app_get_data' ), true ) ) {
505 throw $this->appdata_verification_error();
506 }
507 throw new \Exception( sprintf( __( 'KeyLockr returned an error: %s', 'dologin' ), $code ) );
508 }
509 throw new \Exception( __( 'Invalid KeyLockr SSO response.', 'dologin' ) );
510 }
511
512 if ( 'connected' === $action ) {
513 if ( ! isset( $body['status'] ) || 'ok' !== $body['status'] ) {
514 throw new \Exception( __( 'Invalid KeyLockr SSO response.', 'dologin' ) );
515 }
516 return array(
517 'status' => 'connected',
518 'message' => __( 'Connected to KeyLockr. Waiting for authorization...', 'dologin' ),
519 );
520 }
521
522 if ( 'app_auth_result' === $action ) {
523 $tmp_id = isset( $body['tmp_id'] ) && is_string( $body['tmp_id'] ) ? $body['tmp_id'] : '';
524 if ( empty( $state['tmp_id'] ) || '' === $tmp_id || ! hash_equals( $state['tmp_id'], $tmp_id ) ) {
525 throw $this->app_auth_terminal_error( __( 'KeyLockr authorization did not match this login request.', 'dologin' ) );
526 }
527
528 $status = isset( $body['status'] ) && is_string( $body['status'] ) ? $body['status'] : '';
529 if ( 'error' === $status ) {
530 $code = isset( $body['code'] ) && is_string( $body['code'] ) ? trim( $body['code'] ) : '';
531 if ( '' === $code ) {
532 throw $this->app_auth_terminal_error( __( 'KeyLockr returned an invalid authorization error.', 'dologin' ) );
533 }
534 if ( 'app_auth_result_too_large' === $code ) {
535 throw $this->app_auth_terminal_error( __( 'KeyLockr authorization result was too large. Reduce the service payload and scan again.', 'dologin' ) );
536 }
537 throw $this->app_auth_terminal_error(
538 sprintf( __( 'KeyLockr authorization failed: %s', 'dologin' ), sanitize_text_field( $code ) ),
539 $this->authorization_denied_code( $code )
540 );
541 }
542 if ( 'done' !== $status ) {
543 throw $this->app_auth_terminal_error( __( 'Invalid KeyLockr SSO response.', 'dologin' ) );
544 }
545
546 $app_id = isset( $body['app_id'] ) && is_string( $body['app_id'] ) ? $body['app_id'] : '';
547 $safe_id = isset( $body['safe_id'] ) && is_string( $body['safe_id'] ) ? $body['safe_id'] : '';
548 if ( ! $this->valid_identity_id( $app_id ) || ! $this->valid_identity_id( $safe_id ) ) {
549 throw $this->app_auth_terminal_error( __( 'KeyLockr did not return SSO identity.', 'dologin' ) );
550 }
551 $capabilities = $this->response_capabilities( $body );
552 if ( false === $capabilities ) {
553 throw $this->app_auth_terminal_error( __( 'Invalid KeyLockr SSO response.', 'dologin' ) );
554 }
555 sort( $capabilities, SORT_STRING );
556 if ( array( 'app_data', 'sso' ) !== $capabilities ) {
557 throw $this->app_auth_terminal_error( __( 'KeyLockr returned unexpected SSO capabilities.', 'dologin' ) );
558 }
559
560 try {
561 $data_ver = $this->appdata_version( $body );
562 } catch ( \Exception $ex ) {
563 throw $this->app_auth_terminal_error( $ex->getMessage() );
564 }
565 if ( array_key_exists( 'data_deferred', $body ) && true !== $body['data_deferred'] ) {
566 throw $this->app_auth_terminal_error( __( 'KeyLockr returned an invalid deferred app data flag.', 'dologin' ) );
567 }
568 $data_deferred = isset( $body['data_deferred'] ) && true === $body['data_deferred'];
569 if ( $data_deferred && ( array_key_exists( 'data_plain', $body ) || array_key_exists( 'data_encrypted', $body ) ) ) {
570 throw $this->app_auth_terminal_error( __( 'KeyLockr returned app data fields with a deferred result.', 'dologin' ) );
571 }
572 if ( ! $data_deferred && array_key_exists( 'data_encrypted', $body )
573 && ! is_string( $body['data_encrypted'] )
574 && ! ( $body['data_encrypted'] instanceof KLSso_MsgPack_Bin ) ) {
575 throw $this->app_auth_terminal_error( __( 'KeyLockr returned invalid encrypted app data.', 'dologin' ) );
576 }
577
578 if ( ! isset( $body['data_filekey'] )
579 || ( ! is_string( $body['data_filekey'] ) && ! ( $body['data_filekey'] instanceof KLSso_MsgPack_Bin ) ) ) {
580 throw $this->app_auth_terminal_error( __( 'KeyLockr did not return the direct app data filekey required by this site.', 'dologin' ) );
581 }
582 $packed_file_key = $this->bin_value( $body['data_filekey'] );
583 if ( '' === $packed_file_key ) {
584 throw $this->app_auth_terminal_error( __( 'KeyLockr did not return the direct app data filekey required by this site.', 'dologin' ) );
585 }
586 try {
587 $file_key = $this->load_file_key( $state, $body );
588 } catch ( \Exception $ex ) {
589 throw $this->app_auth_terminal_error( $ex->getMessage() );
590 }
591
592 $state['app_id'] = $app_id;
593 $state['safe_id'] = $safe_id;
594 $state['data_ver'] = $data_ver;
595 $state['data_filekey'] = base64_encode( $packed_file_key );
596 $state['verified'] = true;
597 $state['nickname'] = isset( $body['user_nickname'] ) && is_string( $body['user_nickname'] )
598 ? sanitize_text_field( $body['user_nickname'] )
599 : '';
600 $state['id'] = 'app.' . $app_id;
601 $state['phase'] = 'app_read';
602
603 try {
604 if ( $this->appdata_write_mode( $state ) && empty( $state['appdata_written'] ) ) {
605 $res = $this->write_appdata( $state, $file_key );
606 $state['phase'] = 'app_write';
607 $res['status'] = 'reconnect';
608 $res['ws_url'] = $this->ws_url_for( $state['id'], base64_decode( $state['sign_sk'] ) );
609 return $res;
610 }
611 if ( ! $data_deferred && array_key_exists( 'data_encrypted', $body ) ) {
612 $data_enc = $this->bin_value( $body['data_encrypted'] );
613 return $this->process_appdata( $state, $data_enc, $file_key );
614 }
615 } finally {
616 $this->clear_file_key( $file_key );
617 }
618
619 return array(
620 'status' => 'reconnect',
621 'ws_url' => $this->ws_url_for( $state['id'], base64_decode( $state['sign_sk'] ) ),
622 'send' => base64_encode( $this->seal_kps( $state, 'app_get_data', array() ) ),
623 'message' => __( 'Reading KeyLockr app data...', 'dologin' ),
624 );
625 }
626
627 if ( 'app_get_data' === $action ) {
628 if ( empty( $state['data_filekey'] ) ) {
629 throw new \Exception( __( 'KeyLockr app data filekey is missing.', 'dologin' ) );
630 }
631 $packed_file_key = base64_decode( $state['data_filekey'], true );
632 if ( false === $packed_file_key ) {
633 throw new \Exception( __( 'Invalid KeyLockr app data filekey.', 'dologin' ) );
634 }
635 $file_key_body = array(
636 'data_filekey' => KLSso_MsgPack::bin( $packed_file_key ),
637 );
638 $file_key = $this->load_file_key( $state, $file_key_body );
639 $data_enc = $this->bin_value( isset( $body['data_encrypted'] ) ? $body['data_encrypted'] : '' );
640 $state['data_ver'] = $this->appdata_version( $body );
641 try {
642 if ( $this->appdata_write_mode( $state ) && ! empty( $state['appdata_retry_pending'] ) ) {
643 unset( $state['appdata_retry_pending'] );
644 $res = $this->write_appdata( $state, $file_key );
645 $state['phase'] = 'app_write';
646 return $res;
647 }
648 return $this->process_appdata( $state, $data_enc, $file_key );
649 } finally {
650 $this->clear_file_key( $file_key );
651 }
652 }
653
654 if ( 'app_set_data' === $action ) {
655 $file_id_ok = isset( $body['file_id'] ) && is_string( $body['file_id'] ) && '' !== trim( $body['file_id'] );
656 $new_ver = $this->appdata_version( $body );
657 if ( ! $this->appdata_write_mode( $state ) || empty( $state['data_filekey'] ) || empty( $state['app_hash'] ) || empty( $state['data_ver'] ) || ! $file_id_ok || hash_equals( $state['data_ver'], $new_ver ) ) {
658 throw $this->appdata_verification_error();
659 }
660 $state['appdata_written'] = true;
661 $state['data_ver'] = $new_ver;
662 $state['phase'] = 'app_read';
663 return array(
664 'status' => 'send',
665 'send' => base64_encode( $this->seal_kps( $state, 'app_get_data', array() ) ),
666 'message' => __( 'KeyLockr app data saved. Reading it back for verification...', 'dologin' ),
667 );
668 }
669
670 if ( 'ping' === $action ) {
671 return array(
672 'status' => 'send',
673 'send' => base64_encode( $this->seal_kps( $state, 'pong', array() ) ),
674 );
675 }
676
677 throw new \Exception( __( 'Unexpected KeyLockr SSO message.', 'dologin' ) );
678 }
679
680 /**
681 * Decrypt and verify the AppData hash, or write it during binding and repair.
682 */
683 private function process_appdata( &$state, $data_enc, $file_key ) {
684 if ( ! $file_key ) {
685 throw new \Exception( __( 'KeyLockr app data key is missing.', 'dologin' ) );
686 }
687
688 $data = null;
689 if ( $data_enc ) {
690 try {
691 $plain = $this->decrypt_appdata( $data_enc, $file_key );
692 if ( false === $plain ) {
693 throw new \Exception( __( 'Failed to decrypt KeyLockr app data.', 'dologin' ) );
694 }
695 $data = KLSso_MsgPack::unpack( $plain );
696 } catch ( \Exception $ex ) {
697 if ( $this->appdata_write_mode( $state ) ) {
698 throw $this->appdata_verification_error();
699 }
700 throw $ex;
701 }
702 }
703
704 if ( $this->appdata_write_mode( $state ) ) {
705 $expected_hash = self::user_app_hash( $state['user_id'] );
706 if ( ! is_array( $data ) || ! isset( $data['hash'] ) || ! is_scalar( $data['hash'] ) || ! hash_equals( $expected_hash, (string) $data['hash'] ) ) {
707 throw $this->appdata_verification_error();
708 }
709 $state['app_hash'] = (string) $data['hash'];
710 $state['app_hash_ok'] = true;
711 return $this->complete_verified_session( $state );
712 }
713
714 if ( ! is_array( $data ) || ! isset( $data['hash'] ) || ! is_scalar( $data['hash'] ) ) {
715 if ( 'verify' === $state['mode'] ) {
716 throw $this->repair_required_error();
717 }
718 throw $this->login_identity_error( __( 'Please link this WordPress account with KeyLockr SSO first.', 'dologin' ) );
719 }
720 $state['app_hash'] = (string) $data['hash'];
721 return $this->complete_verified_session( $state );
722 }
723
724 /**
725 * Write KeyLockr verification data for the current WordPress user.
726 */
727 private function write_appdata( &$state, $file_key ) {
728 if ( isset( $state['mode'] ) && 'repair' === $state['mode'] ) {
729 $this->validate_repair_state( $state );
730 } else {
731 $this->validate_bind_state( $state );
732 }
733 if ( empty( $state['data_ver'] ) ) {
734 throw $this->appdata_verification_error();
735 }
736 $state['app_hash'] = self::user_app_hash( $state['user_id'] );
737 $state['app_hash_ok'] = false;
738 $state['appdata_written'] = false;
739 $payload = KLSso_MsgPack::pack(
740 array(
741 'hash' => $state['app_hash'],
742 'site' => home_url(),
743 'ts' => time(),
744 'v' => 1,
745 )
746 );
747 $enc = $this->encrypt_appdata( $payload, $file_key );
748
749 return array(
750 'status' => 'send',
751 'send' => base64_encode(
752 $this->seal_kps(
753 $state,
754 'app_set_data',
755 array(
756 'data_enc__' => 0,
757 'ver' => $state['data_ver'],
758 ),
759 array( $enc )
760 )
761 ),
762 'message' => __( 'Writing KeyLockr app data verification hash...', 'dologin' ),
763 );
764 }
765
766 /**
767 * Confirm that the KeyLockr account may be linked to the current WordPress user.
768 */
769 private function validate_bind_state( $state ) {
770 if ( empty( $state['verified'] ) || empty( $state['safe_id'] ) || empty( $state['app_id'] ) ) {
771 throw new \Exception( __( 'KeyLockr SSO identity is not verified.', 'dologin' ) );
772 }
773 $uid = (int) $state['user_id'];
774 if ( ! $uid || ! get_userdata( $uid ) ) {
775 throw new \Exception( __( 'WordPress user is not available for KeyLockr binding.', 'dologin' ) );
776 }
777
778 $existing = get_users(
779 array(
780 'meta_key' => self::META_SAFE_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- rare binding operation.
781 'meta_value' => $state['safe_id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- rare binding operation.
782 'fields' => 'ID',
783 'number' => 2,
784 'count_total' => false,
785 )
786 );
787 foreach ( $existing as $existing_uid ) {
788 if ( (int) $existing_uid !== $uid ) {
789 throw new \Exception( __( 'This KeyLockr account is already linked to another WordPress user.', 'dologin' ) );
790 }
791 }
792 return $uid;
793 }
794
795 /**
796 * Complete a verified bind, repair, test, or login session.
797 */
798 private function complete_verified_session( &$state ) {
799 if ( empty( $state['verified'] ) ) {
800 throw new \Exception( __( 'KeyLockr SSO identity is not verified.', 'dologin' ) );
801 }
802 if ( empty( $state['site_key_fingerprint'] ) || ! self::site_key_fingerprint_matches( $state['site_key_fingerprint'] ) ) {
803 throw new \Exception( __( 'KeyLockr site keys changed. Start a new session.', 'dologin' ) );
804 }
805 if ( empty( $state['app_tag'] ) || ! hash_equals( self::app_tag(), (string) $state['app_tag'] ) ) {
806 throw new \Exception( __( 'KeyLockr App Tag changed. Start a new session.', 'dologin' ) );
807 }
808
809 if ( in_array( $state['mode'], array( 'login', 'verify' ), true ) && empty( $state['app_hash'] ) ) {
810 throw new \Exception( __( 'KeyLockr app data hash is not verified.', 'dologin' ) );
811 }
812 if ( $this->appdata_write_mode( $state ) && empty( $state['app_hash_ok'] ) ) {
813 throw new \Exception( __( 'KeyLockr app data read-back is not verified.', 'dologin' ) );
814 }
815
816 if ( 'verify' === $state['mode'] ) {
817 $uid = (int) $state['user_id'];
818 $safe_id = trim( (string) get_user_meta( $uid, self::META_SAFE_ID, true ) );
819 $expected_hash = trim( (string) get_user_meta( $uid, self::META_APP_HASH, true ) );
820 if ( ! $uid || ! get_userdata( $uid ) || ! $safe_id
821 || empty( $state['safe_id'] )
822 || ! hash_equals( $safe_id, (string) $state['safe_id'] ) ) {
823 throw new \Exception( __( 'KeyLockr SSO verification does not match this WordPress account.', 'dologin' ) );
824 }
825 if ( ! $expected_hash || ! hash_equals( $expected_hash, (string) $state['app_hash'] ) ) {
826 throw $this->repair_required_error( true );
827 }
828 $state['app_hash_ok'] = true;
829 update_user_meta( $uid, self::META_SITE_KEY_FP, $state['site_key_fingerprint'] );
830 update_user_meta( $uid, self::META_APP_TAG, $state['app_tag'] );
831 return array(
832 'status' => 'done',
833 '_delete_state' => true,
834 'message' => __( 'KeyLockr SSO verification succeeded.', 'dologin' ),
835 'reload' => false,
836 );
837 }
838
839 if ( 'login' === $state['mode'] && ( $this->cls( 'Auth' )->is_ip_denied() || $this->cls( 'Auth' )->is_rate_limited() ) ) {
840 throw new \Exception( __( 'This IP is not allowed to login.', 'dologin' ) );
841 }
842
843 if ( 'repair' === $state['mode'] ) {
844 return $this->complete_repair_session( $state );
845 }
846
847 if ( 'bind' === $state['mode'] ) {
848 $uid = $this->validate_bind_state( $state );
849
850 update_user_meta( $uid, self::META_SAFE_ID, $state['safe_id'] );
851 update_user_meta( $uid, self::META_NICKNAME, $state['nickname'] );
852 update_user_meta( $uid, self::META_SITE_KEY_FP, $state['site_key_fingerprint'] );
853 update_user_meta( $uid, self::META_APP_TAG, $state['app_tag'] );
854
855 return array(
856 'status' => 'done',
857 '_delete_state' => true,
858 'message' => __( 'KeyLockr SSO linked successfully.', 'dologin' ),
859 );
860 }
861
862 $users = get_users(
863 array(
864 'meta_key' => self::META_SAFE_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- SSO login lookup.
865 'meta_value' => $state['safe_id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- SSO login lookup.
866 'fields' => 'all',
867 'number' => 2,
868 'count_total' => false,
869 )
870 );
871 if ( 1 !== count( $users ) ) {
872 throw $this->login_identity_error( __( 'The KeyLockr account link is missing or ambiguous.', 'dologin' ) );
873 }
874
875 $user = $users[0];
876 $expected_hash = get_user_meta( $user->ID, self::META_APP_HASH, true );
877 if ( ! $expected_hash || empty( $state['app_hash'] ) || ! hash_equals( (string) $expected_hash, (string) $state['app_hash'] ) ) {
878 throw $this->login_identity_error( __( 'KeyLockr app data hash does not match this WordPress account.', 'dologin' ) );
879 }
880 $state['app_hash_ok'] = true;
881 update_user_meta( $user->ID, self::META_SITE_KEY_FP, $state['site_key_fingerprint'] );
882 update_user_meta( $user->ID, self::META_APP_TAG, $state['app_tag'] );
883
884 wp_set_current_user( $user->ID );
885 wp_set_auth_cookie( $user->ID, false, is_ssl() );
886 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- firing WordPress core login hook.
887 do_action( 'wp_login', $user->user_login, $user );
888
889 return array(
890 'status' => 'done',
891 '_delete_state' => true,
892 'message' => __( 'Logged in with KeyLockr SSO.', 'dologin' ),
893 'redirect' => apply_filters( 'login_redirect', admin_url(), '', $user ),
894 );
895 }
896
897 /**
898 * Is libsodium available.
899 */
900 public static function sodium_ready() {
901 return function_exists( 'sodium_crypto_box_keypair' )
902 && function_exists( 'sodium_crypto_box' )
903 && function_exists( 'sodium_crypto_box_open' )
904 && function_exists( 'sodium_crypto_box_keypair_from_secretkey_and_publickey' )
905 && function_exists( 'sodium_crypto_secretbox' )
906 && function_exists( 'sodium_crypto_secretbox_open' )
907 && function_exists( 'sodium_crypto_sign_keypair' )
908 && function_exists( 'sodium_crypto_sign' )
909 && function_exists( 'sodium_crypto_sign_open' );
910 }
911
912 /**
913 * Extract binary bytes from MsgPack bin wrapper.
914 */
915 private function bin_value( $value ) {
916 return $value instanceof KLSso_MsgPack_Bin ? $value->bytes : (string) $value;
917 }
918
919 /**
920 * URL-safe base64 without padding.
921 */
922 private function base64url_encode( $data ) {
923 return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
924 }
925
926 /**
927 * Count failed SSO login attempts when appropriate.
928 */
929 private function fail_login_if_needed( $state, $exception ) {
930 $code = $exception instanceof \Exception ? $exception->getCode() : 0;
931 if ( is_array( $state )
932 && isset( $state['mode'] )
933 && 'login' === $state['mode']
934 && in_array( $code, array( self::AUTH_DENIED_CODE, self::LOGIN_IDENTITY_CODE ), true ) ) {
935 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- firing WordPress core failure hook.
936 do_action( 'wp_login_failed', 'keylockr_sso' );
937 }
938 }
939
940 /**
941 * Whether a classified protocol error has ended this short-lived session.
942 */
943 private function is_terminal_kps_error( $exception ) {
944 return $exception instanceof \Exception
945 && in_array(
946 $exception->getCode(),
947 array( self::APP_AUTH_TERMINAL_CODE, self::AUTH_DENIED_CODE, self::LOGIN_IDENTITY_CODE ),
948 true
949 );
950 }
951 }
952