PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / 1.8.0
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel v1.8.0
1.8.0 1.7.1 1.7.0 1.6.0 1.5.2 trunk 0.1 0.2.0 0.2.1 0.3 0.3.1 0.3.2 0.3.3 0.3.4 0.4 0.4.1 0.4.2 0.4.3 1.0 1.1 1.2 1.3.1 1.4.0 1.4.1 1.5.0 All 27 releases
← All changes | includes/MagicLoginToken.php +59 -55 1.6.0 → 1.8.0 View file →
@@ -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 }