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-protocol.cls.php

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

443 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * KeyLockr SSO protocol and cryptographic helpers.
4 *
5 * @since 4.6.5
6 * @package dologin
7 */
8
9 namespace dologin;
10
11 defined( 'WPINC' ) || exit;
12
13 trait KLSso_Protocol {
14 /**
15 * Decode the file key included in the authorization completion.
16 */
17 private function load_file_key( $state, $body ) {
18 $packed = $this->bin_value( isset( $body['data_filekey'] ) ? $body['data_filekey'] : '' );
19 if ( ! $packed ) {
20 throw new \Exception( __( 'KeyLockr did not return app data filekey.', 'dologin' ) );
21 }
22
23 $data = KLSso_MsgPack::unpack( $packed );
24 if ( ! is_array( $data ) || ! isset( $data['encFileKey'], $data['nonceForKey'], $data['apEncPk'] ) ) {
25 throw new \Exception( __( 'Invalid KeyLockr app data filekey.', 'dologin' ) );
26 }
27 $enc_file_key = $this->bin_value( $data['encFileKey'] );
28 $nonce_for_key = $this->bin_value( $data['nonceForKey'] );
29 $sender_pk = $this->bin_value( $data['apEncPk'] );
30
31 if ( strlen( $enc_file_key ) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES + SODIUM_CRYPTO_BOX_MACBYTES
32 || strlen( $nonce_for_key ) !== SODIUM_CRYPTO_BOX_NONCEBYTES ) {
33 throw new \Exception( __( 'Invalid KeyLockr app data filekey.', 'dologin' ) );
34 }
35 if ( strlen( $sender_pk ) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES ) {
36 throw new \Exception( __( 'KeyLockr app data filekey is missing sender public key.', 'dologin' ) );
37 }
38
39 $enc_sk = empty( $state['enc_sk'] ) ? false : base64_decode( $state['enc_sk'], true );
40 if ( false === $enc_sk || strlen( $enc_sk ) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES ) {
41 throw new \Exception( __( 'Invalid KeyLockr SSO session key.', 'dologin' ) );
42 }
43 $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey( $enc_sk, $sender_pk );
44 $key = sodium_crypto_box_open( $enc_file_key, $nonce_for_key, $keypair );
45 if ( false === $key || strlen( $key ) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES ) {
46 throw new \Exception( __( 'Failed to decrypt KeyLockr app data filekey.', 'dologin' ) );
47 }
48
49 return $key;
50 }
51
52 /**
53 * Decrypt nonce-prefixed AppData.
54 */
55 private function decrypt_appdata( $data_enc, $file_key ) {
56 if ( strlen( $data_enc ) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES ) {
57 return false;
58 }
59 $nonce = substr( $data_enc, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
60 return sodium_crypto_secretbox_open( substr( $data_enc, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), $nonce, $file_key );
61 }
62
63 /**
64 * Encrypt AppData with a fresh nonce prefixed to the secretbox ciphertext.
65 */
66 private function encrypt_appdata( $plain, $file_key ) {
67 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
68 return $nonce . sodium_crypto_secretbox( $plain, $nonce, $file_key );
69 }
70
71 /**
72 * Best-effort clear the raw file key after a request.
73 */
74 private function clear_file_key( &$file_key ) {
75 if ( $file_key && function_exists( 'sodium_memzero' ) ) {
76 sodium_memzero( $file_key );
77 }
78 $file_key = '';
79 }
80
81 /**
82 * Build an AppData read-back verification error.
83 */
84 private function appdata_verification_error() {
85 return new \Exception( __( 'KeyLockr app data write could not be verified. Unlock KeyLockr on your phone and try linking again.', 'dologin' ) );
86 }
87
88 /**
89 * Build an error for exhausted AppData version-conflict retries.
90 */
91 private function appdata_conflict_error() {
92 return new \Exception( __( 'KeyLockr app data changed repeatedly while linking. Please scan again.', 'dologin' ) );
93 }
94
95 /**
96 * Build an error for a service without App Data Storage.
97 */
98 private function appdata_disabled_error() {
99 return new \Exception( __( 'KeyLockr app data was not found. This usually means App Data Storage is disabled for this service. Open MyDeveloper, edit this App Tag, enable App Data Storage, and scan again.', 'dologin' ) );
100 }
101
102 /**
103 * Build a terminal authorization-completion error.
104 */
105 private function app_auth_terminal_error( $message, $authorization_denied = false ) {
106 return new \Exception( $message, $authorization_denied ? self::AUTH_DENIED_CODE : self::APP_AUTH_TERMINAL_CODE );
107 }
108
109 /**
110 * Build an identity mismatch that counts only during a login session.
111 */
112 private function login_identity_error( $message ) {
113 return new \Exception( $message, self::LOGIN_IDENTITY_CODE );
114 }
115
116 /**
117 * Validate an unchanged KeyLockr SERIAL identifier.
118 */
119 private function valid_identity_id( $value ) {
120 if ( ! is_string( $value ) || ! preg_match( '/^[1-9][0-9]{0,9}$/D', $value ) ) {
121 return false;
122 }
123 return strlen( $value ) < 10 || strcmp( $value, '2147483647' ) <= 0;
124 }
125
126 /**
127 * Validate an unchanged 36-character KeyLockr handshake identifier.
128 */
129 private function valid_tmp_id( $value ) {
130 return is_string( $value ) && 1 === preg_match( '/^[A-Za-z0-9-]{36}$/D', $value );
131 }
132
133 /**
134 * Whether a terminal completion code explicitly says the user denied authorization.
135 */
136 private function authorization_denied_code( $code ) {
137 return in_array( $code, array( 'authorization_denied', 'app_auth_denied', 'safe_auth_denied', 'denied' ), true );
138 }
139
140 /**
141 * Read the current AppData CAS version.
142 */
143 private function appdata_version( $body ) {
144 if ( ! isset( $body['ver'] ) || ! is_string( $body['ver'] ) || ! preg_match( '/^[0-9]+$/', $body['ver'] ) ) {
145 throw new \Exception( __( 'KeyLockr returned an invalid app data version.', 'dologin' ) );
146 }
147 return $body['ver'];
148 }
149
150 /**
151 * Convert current handshake error codes into actionable messages.
152 */
153 private function handshake_error_message( $body ) {
154 $code = isset( $body['code'] ) && is_scalar( $body['code'] ) ? sanitize_text_field( (string) $body['code'] ) : 'unknown_error';
155 if ( 'sso_data_not_approved' === $code ) {
156 return __( 'KeyLockr App Data Storage is not approved for this App Tag. Enable it in MyDeveloper and try again.', 'dologin' );
157 }
158 if ( in_array( $code, array( 'app_tag_invalid', 'sso_service_invalid' ), true ) ) {
159 return __( 'KeyLockr could not resolve this App Tag. Check the effective App Tag in MyDeveloper and try again.', 'dologin' );
160 }
161 return sprintf( __( 'KeyLockr handshake failed: %s', 'dologin' ), $code );
162 }
163
164 /**
165 * Parse and validate capabilities from the authorization result.
166 */
167 private function response_capabilities( $body ) {
168 if ( ! isset( $body['capabilities'] ) || ! is_array( $body['capabilities'] ) ) {
169 return false;
170 }
171 $capabilities = array();
172 foreach ( $body['capabilities'] as $capability ) {
173 if ( ! is_string( $capability ) || '' === $capability ) {
174 return false;
175 }
176 $capabilities[] = $capability;
177 }
178 return $capabilities;
179 }
180
181 /**
182 * Read the latest version after an AppData CAS conflict and retry once.
183 */
184 private function retry_appdata_write( &$state ) {
185 if ( empty( $state['data_filekey'] ) ) {
186 throw $this->appdata_verification_error();
187 }
188 $retry_count = isset( $state['appdata_cas_retries'] ) ? (int) $state['appdata_cas_retries'] : 0;
189 if ( $retry_count >= self::APPDATA_CAS_RETRY_LIMIT ) {
190 throw $this->appdata_conflict_error();
191 }
192 $state['appdata_cas_retries'] = $retry_count + 1;
193 $state['appdata_retry_pending'] = true;
194 return array(
195 'status' => 'send',
196 'send' => base64_encode( $this->seal_kps( $state, 'app_get_data', array() ) ),
197 'message' => __( 'KeyLockr app data changed while linking. Reading the latest version before retrying...', 'dologin' ),
198 );
199 }
200
201 /**
202 * Open and validate an incoming KPS frame.
203 */
204 private function open_kps( $state, $frame ) {
205 $root = KLSso_MsgPack::unpack( $frame );
206 if ( ! is_array( $root ) || empty( $root['kps'] ) || ! is_array( $root['kps'] ) || empty( $root['seal'] ) ) {
207 throw new \Exception( __( 'Invalid KeyLockr KPS frame.', 'dologin' ) );
208 }
209
210 $kps = $root['kps'];
211 $seal = $this->bin_value( $root['seal'] );
212 $sign_pk = isset( $state['server_sign_pk'] ) ? base64_decode( $state['server_sign_pk'], true ) : false;
213 $box = empty( $kps['box'] ) ? '' : $this->bin_value( $kps['box'] );
214 if ( ! $box || empty( $kps['n'] ) || false === $sign_pk || strlen( $sign_pk ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES || strlen( $seal ) !== SODIUM_CRYPTO_SIGN_BYTES + 32 ) {
215 throw new \Exception( __( 'Invalid KeyLockr KPS frame.', 'dologin' ) );
216 }
217 $got_hash = sodium_crypto_sign_open( $seal, $sign_pk );
218 if ( false === $got_hash ) {
219 throw new \Exception( __( 'Invalid KeyLockr KPS signature.', 'dologin' ) );
220 }
221
222 $want_hash = hash( 'sha256', KLSso_MsgPack::pack( $kps ), true );
223 if ( ! hash_equals( $want_hash, $got_hash ) ) {
224 throw new \Exception( __( 'Invalid KeyLockr KPS hash.', 'dologin' ) );
225 }
226
227 $enc_sk = isset( $state['enc_sk'] ) ? base64_decode( $state['enc_sk'], true ) : false;
228 $server_enc_pk = isset( $state['server_enc_pk'] ) ? base64_decode( $state['server_enc_pk'], true ) : false;
229 $nonce = $this->bin_value( $kps['n'] );
230 if ( false === $enc_sk || false === $server_enc_pk || strlen( $enc_sk ) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES || strlen( $server_enc_pk ) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES || strlen( $nonce ) !== SODIUM_CRYPTO_BOX_NONCEBYTES ) {
231 throw new \Exception( __( 'Invalid KeyLockr KPS encryption data.', 'dologin' ) );
232 }
233 $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey( $enc_sk, $server_enc_pk );
234 $plain = sodium_crypto_box_open( $box, $nonce, $keypair );
235 if ( false === $plain ) {
236 throw new \Exception( __( 'Failed to decrypt KeyLockr KPS frame.', 'dologin' ) );
237 }
238
239 $inner = KLSso_MsgPack::unpack( $plain );
240 if ( ! is_array( $inner ) ) {
241 throw new \Exception( __( 'Invalid KeyLockr KPS frame.', 'dologin' ) );
242 }
243 $header = isset( $inner['header'] ) && is_array( $inner['header'] ) ? $inner['header'] : array();
244 $body = isset( $inner['body'] ) && is_array( $inner['body'] ) ? $inner['body'] : array();
245 $timestamp = isset( $header['ts'] ) ? filter_var( $header['ts'], FILTER_VALIDATE_INT ) : false;
246 if ( empty( $header['from'] ) || false === $timestamp || ! $this->timestamp_valid( (int) $timestamp ) ) {
247 throw new \Exception( __( 'Invalid or expired KeyLockr KPS response.', 'dologin' ) );
248 }
249 $raw = array();
250 if ( isset( $kps['raw'] ) && ! is_array( $kps['raw'] ) ) {
251 throw new \Exception( __( 'Invalid KeyLockr KPS frame.', 'dologin' ) );
252 }
253 if ( isset( $kps['raw'] ) ) {
254 foreach ( $kps['raw'] as $raw_item ) {
255 if ( ! is_string( $raw_item ) && ! ( $raw_item instanceof KLSso_MsgPack_Bin ) ) {
256 throw new \Exception( __( 'Invalid KeyLockr KPS frame.', 'dologin' ) );
257 }
258 $raw[] = $this->bin_value( $raw_item );
259 }
260 $body = $this->join_raw( $body, $raw );
261 }
262
263 return array(
264 empty( $header['from'] ) ? '' : (string) $header['from'],
265 $body,
266 );
267 }
268
269 /**
270 * Merge KPS raw binary side-channel fields into the decoded body.
271 */
272 private function join_raw( $value, $raw ) {
273 if ( ! is_array( $value ) ) {
274 return $value;
275 }
276
277 foreach ( $value as $key => $item ) {
278 if ( is_string( $key ) && substr( $key, -2 ) === '__' && is_int( $item ) && isset( $raw[ $item ] ) ) {
279 unset( $value[ $key ] );
280 $value[ substr( $key, 0, -2 ) ] = KLSso_MsgPack::bin( $raw[ $item ] );
281 continue;
282 }
283 $value[ $key ] = $this->join_raw( $item, $raw );
284 }
285
286 return $value;
287 }
288
289 /**
290 * Build an outgoing KPS frame.
291 */
292 private function seal_kps( $state, $action, $body, $raw = array() ) {
293 $inner = KLSso_MsgPack::pack(
294 array(
295 'body' => $body,
296 'header' => array(
297 'to' => $action,
298 'ts' => time(),
299 ),
300 )
301 );
302
303 $nonce = random_bytes( SODIUM_CRYPTO_BOX_NONCEBYTES );
304 $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey( base64_decode( $state['enc_sk'] ), base64_decode( $state['server_enc_pk'] ) );
305 $box = sodium_crypto_box( $inner, $nonce, $keypair );
306 $kps = array(
307 'app_ver' => 'dologin-' . Core::VER,
308 'box' => KLSso_MsgPack::bin( $box ),
309 'id' => $state['id'],
310 'n' => KLSso_MsgPack::bin( $nonce ),
311 );
312 if ( $raw ) {
313 $kps['raw'] = array();
314 foreach ( $raw as $raw_item ) {
315 if ( ! is_string( $raw_item ) && ! ( $raw_item instanceof KLSso_MsgPack_Bin ) ) {
316 throw new \Exception( __( 'Invalid KeyLockr KPS raw data.', 'dologin' ) );
317 }
318 $kps['raw'][] = KLSso_MsgPack::bin( $this->bin_value( $raw_item ) );
319 }
320 }
321 $hash = hash( 'sha256', KLSso_MsgPack::pack( $kps ), true );
322 $seal = sodium_crypto_sign( $hash, base64_decode( $state['sign_sk'] ) );
323
324 return KLSso_MsgPack::pack(
325 array(
326 'kps' => $kps,
327 'seal' => KLSso_MsgPack::bin( $seal ),
328 )
329 );
330 }
331
332 /**
333 * Build a signed WebSocket URL for a KPS identity.
334 */
335 private function ws_url_for( $id, $sign_sk ) {
336 $ts = time();
337 $sig = sodium_crypto_sign( $id . '.' . $ts, $sign_sk );
338 return add_query_arg( 'sig', $this->base64url_encode( $sig ), self::ws_url() );
339 }
340
341 /**
342 * Confirm that the site clock is within the window accepted by KeyLockr.
343 */
344 private function check_server_clock() {
345 if ( get_transient( self::CLOCK_CACHE ) ) {
346 return true;
347 }
348
349 $res = wp_safe_remote_get(
350 self::api_base() . '/clock',
351 array(
352 'timeout' => 10,
353 'redirection' => 0,
354 'limit_response_size' => 1024,
355 'sslverify' => true,
356 )
357 );
358 if ( is_wp_error( $res ) ) {
359 return $res;
360 }
361 if ( 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
362 return new \WP_Error( 'dologin_kl_sso_clock_status', __( 'KeyLockr clock request returned an invalid status.', 'dologin' ) );
363 }
364
365 $body = json_decode( wp_remote_retrieve_body( $res ), true );
366 $timestamp = is_array( $body ) && isset( $body['ts'] ) ? filter_var( $body['ts'], FILTER_VALIDATE_INT ) : false;
367 if ( ! is_array( $body ) || ! isset( $body['_res'] ) || 'ok' !== $body['_res'] || false === $timestamp ) {
368 return new \WP_Error( 'dologin_kl_sso_clock_response', __( 'KeyLockr returned invalid clock data.', 'dologin' ) );
369 }
370
371 $drift = time() - (int) $timestamp;
372 if ( $drift > self::CLOCK_FUTURE_LIMIT || $drift < -self::CLOCK_PAST_LIMIT ) {
373 return new \WP_Error( 'dologin_kl_sso_clock_drift', __( 'This server clock is out of sync with KeyLockr. Synchronize the server time and try again.', 'dologin' ) );
374 }
375 set_transient( self::CLOCK_CACHE, 1, 10 * MINUTE_IN_SECONDS );
376 return true;
377 }
378
379 /**
380 * Check whether a KeyLockr response timestamp is within the protocol window.
381 */
382 private function timestamp_valid( $timestamp ) {
383 $now = time();
384 return $timestamp >= $now - self::CLOCK_PAST_LIMIT && $timestamp <= $now + self::CLOCK_FUTURE_LIMIT;
385 }
386
387 /**
388 * Fetch and cache server public keys.
389 */
390 private function server_keys() {
391 $cached = get_transient( self::SERVER_KEYS_CACHE );
392 if ( is_array( $cached ) && ! empty( $cached['enc_pk'] ) && ! empty( $cached['sign_pk'] ) ) {
393 $cached_enc = base64_decode( $cached['enc_pk'], true );
394 $cached_sign = base64_decode( $cached['sign_pk'], true );
395 if ( strlen( (string) $cached_enc ) === SODIUM_CRYPTO_BOX_PUBLICKEYBYTES && strlen( (string) $cached_sign ) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) {
396 return array(
397 'enc_pk' => $cached_enc,
398 'sign_pk' => $cached_sign,
399 );
400 }
401 delete_transient( self::SERVER_KEYS_CACHE );
402 }
403
404 $request_args = array(
405 'timeout' => 10,
406 'redirection' => 1,
407 'limit_response_size' => 1024,
408 'sslverify' => true,
409 );
410 $enc = wp_safe_remote_get( self::api_base() . '/key_enc', $request_args );
411 $sign = wp_safe_remote_get( self::api_base() . '/key_sign', $request_args );
412 if ( is_wp_error( $enc ) ) {
413 return $enc;
414 }
415 if ( is_wp_error( $sign ) ) {
416 return $sign;
417 }
418 if ( 200 !== (int) wp_remote_retrieve_response_code( $enc ) || 200 !== (int) wp_remote_retrieve_response_code( $sign ) ) {
419 return new \WP_Error( 'dologin_kl_sso_server_key_status', __( 'KeyLockr server key request returned an invalid status.', 'dologin' ) );
420 }
421
422 $enc_pk = base64_decode( trim( wp_remote_retrieve_body( $enc ) ), true );
423 $sign_pk = base64_decode( trim( wp_remote_retrieve_body( $sign ) ), true );
424 if ( strlen( (string) $enc_pk ) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES || strlen( (string) $sign_pk ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) {
425 return new \WP_Error( 'dologin_kl_sso_bad_server_keys', __( 'Invalid KeyLockr server public keys.', 'dologin' ) );
426 }
427
428 set_transient(
429 self::SERVER_KEYS_CACHE,
430 array(
431 'enc_pk' => base64_encode( $enc_pk ),
432 'sign_pk' => base64_encode( $sign_pk ),
433 ),
434 DAY_IN_SECONDS
435 );
436
437 return array(
438 'enc_pk' => $enc_pk,
439 'sign_pk' => $sign_pk,
440 );
441 }
442 }
443