| @@ -4,20 +4,17 @@ | ||
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | 6 | * Verifier for the signed, single-use tokens the FlyWP control plane mints for magic login. |
| 7 | 7 | * |
| 8 | - * The token format is a contract shared with the control plane, so it lives in one place with | |
| 9 | - * no WordPress dependency at all — it can be reasoned about, and tested, on its own. | |
| 8 | + * The format is a contract shared with the control plane, so it lives in one place with no | |
| 9 | + * WordPress dependency and can be tested on its own. | |
| 10 | 10 | * |
| 11 | - * flywp1.<b64url(payload_json)>.<b64url(hmac_sha256)> | |
| 11 | + * flywp-ed25519.<b64url(payload_json)>.<b64url(ed25519_signature)> | |
| 12 | 12 | * |
| 13 | - * Two properties of that shape are deliberate. The version prefix is part of the signed | |
| 14 | - * material, so the scheme cannot be downgraded by rewriting it. And the signature is verified | |
| 15 | - * against the payload bytes exactly as they arrived, never against a re-encoding of the decoded | |
| 16 | - * claims — the control plane runs PHP 8.3 and sites run anything from 7.4 up, and JSON | |
| 17 | - * canonicalisation is not worth betting a login on. | |
| 13 | + * Two rules when changing this: the prefix is part of the signed material, and the signature is | |
| 14 | + * verified against the payload bytes as received, never against a re-encoding of the claims. | |
| 18 | 15 | * |
| 19 | - * @since 1.6.0 | |
| 16 | + * @since 1.7.0 | |
| 20 | 17 | */ |
| 21 | 18 | class MagicLoginToken { |
| 22 | 19 | |
| 23 | 20 | /** |
| @@ -22,9 +19,9 @@ | ||
| 22 | 19 | |
| 23 | 20 | /** |
| 24 | 21 | * Token version. Part of the signed material. |
| 25 | 22 | */ |
| 26 | - const VERSION = 'flywp1'; | |
| 23 | + const VERSION = 'flywp-ed25519'; | |
| 27 | 24 | |
| 28 | 25 | /** |
| 29 | 26 | * Longest token accepted, in bytes. Bounds the work done before the signature check. |
| 30 | 27 | */ |
| @@ -42,24 +39,20 @@ | ||
| 42 | 39 | |
| 43 | 40 | /** |
| 44 | 41 | * Verify a token and return the claims it carries. |
| 45 | 42 | * |
| 46 | - * @param string $token Raw token as it arrived in the request. | |
| 47 | - * @param string $api_key This site's FLYWP_API_KEY. | |
| 48 | - * @param int $now Current unix timestamp. | |
| 49 | - * @param int $skew Clock drift to tolerate, in seconds. | |
| 43 | + * @param string $token Raw token as it arrived in the request. | |
| 44 | + * @param string $public_key This site's FLYWP_LOGIN_PUBLIC_KEY (base64). | |
| 45 | + * @param int $now Current unix timestamp. | |
| 46 | + * @param int $skew Clock drift to tolerate, in seconds. | |
| 50 | 47 | * |
| 51 | 48 | * @return array|null The claims, or null when the token is not acceptable for any reason. |
| 52 | 49 | */ |
| 53 | - public static function parse( $token, $api_key, $now, $skew = self::DEFAULT_SKEW ) { | |
| 54 | - if ( ! is_string( $token ) || ! is_string( $api_key ) || $api_key === '' ) { | |
| 50 | + public static function parse( $token, $public_key, $now, $skew = self::DEFAULT_SKEW ) { | |
| 51 | + if ( ! is_string( $token ) || $token === '' || strlen( $token ) > self::MAX_LENGTH ) { | |
| 55 | 52 | return null; |
| 56 | 53 | } |
| 57 | 54 | |
| 58 | - if ( $token === '' || strlen( $token ) > self::MAX_LENGTH ) { | |
| 59 | - return null; | |
| 60 | - } | |
| 61 | - | |
| 62 | 55 | $parts = explode( '.', $token ); |
| 63 | 56 | |
| 64 | 57 | if ( count( $parts ) !== 3 || $parts[0] !== self::VERSION ) { |
| 65 | 58 | return null; |
| @@ -64,51 +57,59 @@ | ||
| 64 | 57 | if ( count( $parts ) !== 3 || $parts[0] !== self::VERSION ) { |
| 65 | 58 | return null; |
| 66 | 59 | } |
| 67 | 60 | |
| 68 | - $signature = self::base64url_decode( $parts[2] ); | |
| 69 | - | |
| 70 | - if ( $signature === null ) { | |
| 61 | + // Verify before reading any claim. | |
| 62 | + if ( ! self::verify( $parts[0] . '.' . $parts[1], $parts[2], $public_key ) ) { | |
| 71 | 63 | return null; |
| 72 | 64 | } |
| 73 | 65 | |
| 74 | - $expected = hash_hmac( 'sha256', $parts[0] . '.' . $parts[1], self::signing_key( $api_key ), true ); | |
| 75 | - | |
| 76 | - if ( ! hash_equals( $expected, $signature ) ) { | |
| 77 | - return null; | |
| 78 | - } | |
| 79 | - | |
| 80 | 66 | $payload = self::base64url_decode( $parts[1] ); |
| 67 | + $claims = $payload === null ? null : json_decode( $payload, true ); | |
| 81 | 68 | |
| 82 | - if ( $payload === null ) { | |
| 83 | - return null; | |
| 84 | - } | |
| 85 | - | |
| 86 | - $claims = json_decode( $payload, true ); | |
| 87 | - | |
| 88 | 69 | if ( ! is_array( $claims ) || ! self::has_valid_claims( $claims ) ) { |
| 89 | 70 | return null; |
| 90 | 71 | } |
| 91 | 72 | |
| 92 | - if ( ! self::is_fresh( $claims, (int) $now, (int) $skew ) ) { | |
| 93 | - return null; | |
| 94 | - } | |
| 95 | - | |
| 96 | - return $claims; | |
| 73 | + return self::is_fresh( $claims, (int) $now, (int) $skew ) ? $claims : null; | |
| 97 | 74 | } |
| 98 | 75 | |
| 99 | 76 | /** |
| 100 | - * The key tokens are actually signed with, derived from the site's API key. | |
| 77 | + * Whether this site's key vouches for the signature over $signed_material. | |
| 101 | 78 | * |
| 102 | - * The API key authenticates the REST API and the site's calls back to the control plane; | |
| 103 | - * deriving a subkey keeps a magic-login signature from being usable as anything else. | |
| 79 | + * Lengths are checked before the libsodium call, which throws on a wrong-size key or | |
| 80 | + * signature. Keep those checks. | |
| 104 | 81 | * |
| 105 | - * @param string $api_key This site's FLYWP_API_KEY. | |
| 82 | + * @param string $signed_material Bytes the signature is supposed to cover. | |
| 83 | + * @param string $encoded_signature Signature segment, base64url. | |
| 84 | + * @param string $public_key This site's FLYWP_LOGIN_PUBLIC_KEY (base64). | |
| 106 | 85 | * |
| 107 | - * @return string Raw binary key. | |
| 86 | + * @return bool | |
| 108 | 87 | */ |
| 109 | - public static function signing_key( $api_key ) { | |
| 110 | - return hash_hmac( 'sha256', 'flywp-magic-login-v1', $api_key, true ); | |
| 88 | + private static function verify( $signed_material, $encoded_signature, $public_key ) { | |
| 89 | + // A host can be built without sodium; refuse rather than skip the check. | |
| 90 | + if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { | |
| 91 | + return false; | |
| 92 | + } | |
| 93 | + | |
| 94 | + if ( ! is_string( $public_key ) || $public_key === '' ) { | |
| 95 | + return false; | |
| 96 | + } | |
| 97 | + | |
| 98 | + $signature = self::base64url_decode( $encoded_signature ); | |
| 99 | + | |
| 100 | + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode | |
| 101 | + $raw_public_key = base64_decode( $public_key, true ); | |
| 102 | + | |
| 103 | + if ( $signature === null || strlen( $signature ) !== SODIUM_CRYPTO_SIGN_BYTES ) { | |
| 104 | + return false; | |
| 105 | + } | |
| 106 | + | |
| 107 | + if ( $raw_public_key === false || strlen( $raw_public_key ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) { | |
| 108 | + return false; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return sodium_crypto_sign_verify_detached( $signature, $signed_material, $raw_public_key ); | |
| 111 | 112 | } |
| 112 | 113 | |
| 113 | 114 | /** |
| 114 | 115 | * Whether every claim is present and of the right type. |
| @@ -117,10 +118,10 @@ | ||
| 117 | 118 | * |
| 118 | 119 | * @return bool |
| 119 | 120 | */ |
| 120 | 121 | private static function has_valid_claims( array $claims ) { |
| 121 | - foreach ( [ 'sub', 'sid', 'iat', 'exp', 'jti' ] as $claim ) { | |
| 122 | - if ( ! isset( $claims[ $claim ] ) ) { | |
| 122 | + foreach ( [ 'sub', 'sid', 'iat', 'exp', 'jti', 'flywp_user_id' ] as $claim ) { | |
| 123 | + if ( ! array_key_exists( $claim, $claims ) ) { | |
| 123 | 124 | return false; |
| 124 | 125 | } |
| 125 | 126 | } |
| 126 | 127 | |
| @@ -127,9 +128,9 @@ | ||
| 127 | 128 | if ( ! is_string( $claims['sub'] ) || $claims['sub'] === '' ) { |
| 128 | 129 | return false; |
| 129 | 130 | } |
| 130 | 131 | |
| 131 | - if ( ! is_int( $claims['sid'] ) || ! is_int( $claims['iat'] ) || ! is_int( $claims['exp'] ) ) { | |
| 132 | + if ( ! is_int( $claims['sid'] ) || ! is_int( $claims['iat'] ) || ! is_int( $claims['exp'] ) || ! is_int( $claims['flywp_user_id'] ) ) { | |
| 132 | 133 | return false; |
| 133 | 134 | } |
| 134 | 135 | |
| 135 | 136 | return is_string( $claims['jti'] ) && preg_match( '/^[a-f0-9]{32}$/', $claims['jti'] ) === 1; |
| @@ -150,12 +151,8 @@ | ||
| 150 | 151 | if ( $lifetime <= 0 || $lifetime > self::MAX_LIFETIME ) { |
| 151 | 152 | return false; |
| 152 | 153 | } |
| 153 | 154 | |
| 154 | - // Both ends, not just expiry. Checking only the upper bound means a site whose clock runs | |
| 155 | - // behind accepts a token for as long as the drift lasts rather than for its lifetime: to | |
| 156 | - // such a site every token looks like it expires comfortably in the future. A site drifted | |
| 157 | - // further than the grace now refuses tokens outright, which is the safe direction to fail. | |
| 158 | 155 | if ( $now + $skew < $claims['iat'] ) { |
| 159 | 156 | return false; |
| 160 | 157 | } |
| 161 | 158 | |
| @@ -173,10 +170,17 @@ | ||
| 173 | 170 | if ( ! is_string( $value ) || preg_match( '/^[A-Za-z0-9\-_]+$/', $value ) !== 1 ) { |
| 174 | 171 | return null; |
| 175 | 172 | } |
| 176 | 173 | |
| 174 | + $padded = strtr( $value, '-_', '+/' ); | |
| 175 | + $remainder = strlen( $padded ) % 4; | |
| 176 | + | |
| 177 | + if ( $remainder !== 0 ) { | |
| 178 | + $padded .= str_repeat( '=', 4 - $remainder ); | |
| 179 | + } | |
| 180 | + | |
| 177 | 181 | // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 178 | - $decoded = base64_decode( strtr( $value, '-_', '+/' ), true ); | |
| 182 | + $decoded = base64_decode( $padded, true ); | |
| 179 | 183 | |
| 180 | 184 | return $decoded === false ? null : $decoded; |
| 181 | 185 | } |
| 182 | 186 | } |