summaryrefslogtreecommitdiff
blob: e0606df3e062b2faace50826fc66575eca68d3ef (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
<?php

namespace MediaWiki\CheckUser;

use Firebase\JWT\JWT;
use MediaWiki\Session\Session;

class TokenManager {
	/** @var string */
	private const SIGNING_ALGO = 'HS256';

	/** @var string|null */
	private $cipherMethod;

	/** @var string */
	private $secret;

	/**
	 * @param string $secret
	 */
	public function __construct(
		string $secret
	) {
		if ( $secret === '' ) {
			throw new \Exception(
				'CheckUser Token Manager requires $wgSecretKey to be set.'
			);
		}
		$this->secret = $secret;
	}

	/**
	 * Creates a token
	 *
	 * @param Session $session
	 * @param array $data
	 * @return string
	 */
	public function encode( Session $session, array $data ) : string {
		$key = $this->getSessionKey( $session );
		return JWT::encode(
			[
				// Expiration Time https://tools.ietf.org/html/rfc7519#section-4.1.4
				'exp' => \MWTimestamp::time() + 86400, // 24 hours from now
				// Encrypt the form data to pevent it from being leaked.
				'data' => $this->encrypt( $data, $this->getInitializationVector( $key ) ),
			],
			$this->getSigningKey( $key ),
			self::SIGNING_ALGO
		);
	}

	/**
	 * Encrypt private data.
	 *
	 * @param mixed $input
	 * @param string $iv
	 * @return string
	 */
	private function encrypt( $input, string $iv ) : string {
		return openssl_encrypt(
			\FormatJson::encode( $input ),
			$this->getCipherMethod(),
			$this->secret,
			0,
			$iv
		);
	}

	/**
	 * Decode the JWT and return the targets.
	 *
	 * @param Session $session
	 * @param string $token
	 * @return array
	 */
	public function decode( Session $session, string $token ) : array {
		$key = $this->getSessionKey( $session );
		$payload = JWT::decode(
			$token,
			$this->getSigningKey( $key ),
			[ self::SIGNING_ALGO ]
		);

		return $this->decrypt(
			$payload->data,
			$this->getInitializationVector( $key )
		);
	}

	/**
	 * Decrypt private data.
	 *
	 * @param string $input
	 * @param string $iv
	 * @return array
	 */
	private function decrypt( string $input, string $iv ) : array {
		$decrypted = openssl_decrypt(
			$input,
			$this->getCipherMethod(),
			$this->secret,
			0,
			$iv
		);

		if ( $decrypted === false ) {
			throw new \Exception( 'Decryption Failed' );
		}

		return \FormatJson::parse( $decrypted, \FormatJson::FORCE_ASSOC )->getValue();
	}

	/**
	 * Get the initialization vector.
	 *
	 * This must be consistent between encryption and decryption
	 * and must be no more than 16 bytes in length.
	 *
	 * @param string $sessionKey
	 * @return string
	 */
	private function getInitializationVector( string $sessionKey ) : string {
		return hash_hmac( 'md5', $sessionKey, $this->secret, true );
	}

	/**
	 * Decide what type of encryption to use, based on system capabilities.
	 *
	 * @see Session::getEncryptionAlgorithm()
	 *
	 * @return string
	 */
	private function getCipherMethod() : string {
		if ( !$this->cipherMethod ) {
			$methods = openssl_get_cipher_methods();
			if ( in_array( 'aes-256-ctr', $methods, true ) ) {
				$this->cipherMethod = 'aes-256-ctr';
			} elseif ( in_array( 'aes-256-cbc', $methods, true ) ) {
				$this->cipherMethod = 'aes-256-cbc';
			} else {
				throw new \Exception( 'No valid cipher method found with openssl_get_cipher_methods()' );
			}
		}

		return $this->cipherMethod;
	}

	/**
	 * Get the session key suitable for the signing key and initialization vector.
	 *
	 * For the initialization vector, this must be consistent between encryption and decryption
	 * and must be no more than 16 bytes in length.
	 *
	 * This is retrieved from the session or randomly generated and stored in the session. This means
	 * that a token cannot be shared between sessions.
	 *
	 * @param Session $session
	 *
	 * @return string
	 */
	private function getSessionKey( Session $session ) : string {
		$key = $session->get( 'CheckUserTokenKey' );
		if ( $key === null ) {
			$key = base64_encode( random_bytes( 16 ) );
			$session->set( 'CheckUserTokenKey', $key );
		}

		return base64_decode( $key );
	}

	/**
	 * Get the signing key.
	 *
	 * @param string $sessionKey
	 * @return string
	 */
	private function getSigningKey( string $sessionKey ) : string {
		return hash_hmac( 'sha256', $sessionKey, $this->secret );
	}
}