*/ private static function load() { $data = Secrets::get_option( self::OPTION, array() ); return isset( $data['keys'] ) && is_array( $data['keys'] ) ? $data['keys'] : array(); } /** * Saves all keys. * * @param array $keys Keys. */ private static function store( array $keys ) { Secrets::update_option( self::OPTION, array( 'keys' => $keys ) ); } /** * Creates a key and returns the one-time blob. * * @param string $label Label. * @param int $days Days until expiry (0 = never). * @param string[] $ips Allowed IPs/CIDRs (empty = any). * @return array{kid: string, blob: string} */ public static function create( $label, $days, array $ips ) { $keys = self::load(); $kid = 'k' . strtolower( wp_generate_password( 10, false, false ) ); $secret = random_bytes( 32 ); $expires = $days > 0 ? time() + (int) $days * DAY_IN_SECONDS : 0; $keys[ $kid ] = array( 'label' => sanitize_text_field( $label ), 'secret' => Secrets::b64url_encode( $secret ), 'created' => time(), 'expires' => $expires, 'ips' => array_values( array_filter( array_map( 'trim', $ips ) ) ), 'last_used' => 0, 'last_ip' => '', 'revoked' => false, ); self::store( $keys ); Logger::audit( 'Connection key created.', array( 'kid' => $kid, 'label' => $label, 'expires' => $expires ) ); $blob = 'srk1.' . Secrets::b64url_encode( (string) wp_json_encode( array( 'u' => home_url(), 'a' => self::endpoints()['ajax'], 'r' => self::endpoints()['rest'], 'k' => $kid, 's' => Secrets::b64url_encode( $secret ), 'e' => $expires, 'n' => get_bloginfo( 'name' ), ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); return array( 'kid' => $kid, 'blob' => $blob, ); } /** * This site's own API addresses, as WordPress reports them, for the site that pulls. * * Another site can't work these out: admin-ajax.php and the REST prefix can both live * somewhere other than the defaults. The home URL route (?accessnow_replica_api=1) needs no * such knowledge, so it is the one that works with keys made before these were included. * * @return array{ajax: string, rest: string} */ public static function endpoints() { global $wp_rewrite; if ( ! ( $wp_rewrite instanceof \WP_Rewrite ) ) { // The API answers before WordPress sets this up (see Early_Dispatcher), and rest_url() // needs it. WordPress builds the same object a moment later; the API request exits first. $wp_rewrite = new \WP_Rewrite(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited } return array( 'ajax' => add_query_arg( 'action', 'accessnow_replica_api', admin_url( 'admin-ajax.php' ) ), 'rest' => rest_url( 'site-replica/v1/api' ), ); } /** * Keys without secrets, for display. * * @return array */ public static function listing() { $out = array(); foreach ( self::load() as $kid => $key ) { unset( $key['secret'] ); $out[ $kid ] = $key; } return $out; } /** * A usable key with its raw secret, or null (unknown, revoked or expired). * * @param string $kid Key ID. * @return array|null */ public static function find( $kid ) { $keys = self::load(); if ( ! is_string( $kid ) || ! isset( $keys[ $kid ] ) ) { return null; } $key = $keys[ $kid ]; if ( ! empty( $key['revoked'] ) || ( $key['expires'] > 0 && $key['expires'] < time() ) ) { return null; } $secret = Secrets::b64url_decode( $key['secret'] ); if ( false === $secret || 32 !== strlen( $secret ) ) { return null; } $key['kid'] = $kid; $key['secret_raw'] = $secret; return $key; } /** * Revokes a key. * * @param string $kid Key ID. */ public static function revoke( $kid ) { $keys = self::load(); if ( isset( $keys[ $kid ] ) ) { $keys[ $kid ]['revoked'] = true; self::store( $keys ); Logger::audit( 'Connection key revoked.', array( 'kid' => $kid ) ); } } /** * Deletes a key record. * * @param string $kid Key ID. */ public static function delete( $kid ) { $keys = self::load(); unset( $keys[ $kid ] ); self::store( $keys ); } /** * Records use (at most every five minutes, to avoid a database write per request). * * @param string $kid Key ID. * @param string $ip Client IP. */ public static function touch( $kid, $ip ) { $keys = self::load(); if ( ! isset( $keys[ $kid ] ) ) { return; } if ( time() - (int) $keys[ $kid ]['last_used'] < 300 && $keys[ $kid ]['last_ip'] === $ip ) { return; } $keys[ $kid ]['last_used'] = time(); $keys[ $kid ]['last_ip'] = $ip; self::store( $keys ); } /** * Whether an IP matches an allowlist (exact addresses or CIDR ranges, IPv4 and IPv6). * * @param string $ip IP. * @param string[] $list Allowlist. */ public static function ip_allowed( $ip, array $list ) { if ( ! $list ) { return true; } $packed = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $packed ) { return false; } foreach ( $list as $entry ) { $parts = explode( '/', $entry, 2 ); $net = @inet_pton( $parts[0] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $net || strlen( $net ) !== strlen( $packed ) ) { continue; } $bits = isset( $parts[1] ) ? (int) $parts[1] : strlen( $net ) * 8; $bytes = intdiv( $bits, 8 ); if ( substr( $packed, 0, $bytes ) !== substr( $net, 0, $bytes ) ) { continue; } $rem = $bits % 8; if ( 0 === $rem ) { return true; } $mask = chr( ( 0xff << ( 8 - $rem ) ) & 0xff ); if ( ( $packed[ $bytes ] & $mask ) === ( $net[ $bytes ] & $mask ) ) { return true; } } return false; } }