Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.63% covered (warning)
87.63%
85 / 97
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TOTPKey
87.63% covered (warning)
87.63%
85 / 97
90.91% covered (success)
90.91%
10 / 11
28.38
0.00% covered (danger)
0.00%
0 / 1
 newFromRandom
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 removeBase32Padding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addBase32Padding
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 newFromArray
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getSecret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEncryptedSecretAndNonce
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 verify
62.50% covered (warning)
62.50%
20 / 32
0.00% covered (danger)
0.00%
0 / 1
7.90
 getModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getEncryptionHelper
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types=1 );
3
4/**
5 * @license GPL-2.0-or-later
6 */
7
8namespace MediaWiki\Extension\OATHAuth\Key;
9
10use Base32\Base32;
11use DomainException;
12use jakobo\HOTP\HOTP;
13use MediaWiki\Context\RequestContext;
14use MediaWiki\Extension\OATHAuth\Module\TOTP;
15use MediaWiki\Extension\OATHAuth\OATHAuthServices;
16use MediaWiki\Extension\OATHAuth\OATHUser;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\MediaWikiServices;
19use UnexpectedValueException;
20use Wikimedia\ObjectCache\EmptyBagOStuff;
21use Wikimedia\Timestamp\ConvertibleTimestamp;
22use Wikimedia\Timestamp\TimestampFormat;
23
24/**
25 * Class representing a two-factor key
26 *
27 * Keys can be tied to OATHUsers
28 *
29 * @ingroup Extensions
30 */
31class TOTPKey extends AuthKey {
32
33    public const int VERSION = 1;
34
35    /** TOTP binary secret */
36    private array $secret;
37
38    public bool $forceReEncrypt = false;
39
40    public static function newFromRandom(): TOTPKey {
41        return new self(
42            null,
43            null,
44            null,
45            // 26 bytes to give at least 128 bits (26 * 8 = 208 bits of entropy)
46            // https://phabricator.wikimedia.org/T396951
47            self::removeBase32Padding( Base32::encode( random_bytes( 26 ) ) ),
48        );
49    }
50
51    /**
52     * @see T408225, T401393
53     */
54    public static function removeBase32Padding( string $paddedBase32String ): string {
55        return rtrim( $paddedBase32String, '=' );
56    }
57
58    public static function addBase32Padding( string $unpaddedBase32String ): string {
59        // Remove any existing padding if it exists
60        $unpaddedBase32String = self::removeBase32Padding( $unpaddedBase32String );
61
62        // If it's already 8 characters, we don't need to do anything
63        $remainder = strlen( $unpaddedBase32String ) % 8;
64        if ( $remainder === 0 ) {
65            return $unpaddedBase32String;
66        }
67
68        // Pad to make to a multiple of 8 as necessary
69        return $unpaddedBase32String . str_repeat( '=', 8 - $remainder );
70    }
71
72    /**
73     * @param array $data
74     * @return TOTPKey|null on invalid data
75     * @throws UnexpectedValueException When encryption is not configured but db is encrypted
76     */
77    public static function newFromArray( array $data, bool $fromMaintenanceScript = false ) {
78        if ( !isset( $data['secret'] ) ) {
79            return null;
80        }
81
82        if (
83            ( !$fromMaintenanceScript && isset( $data['version'] ) && $data['format'] === 'encrypted' ) ||
84            // If we're being called from a maintenance script, allow the old format.
85            ( $fromMaintenanceScript && isset( $data['nonce'] ) )
86        ) {
87            $encryptionHelper = self::getEncryptionHelper();
88            if ( !$encryptionHelper->isEnabled() ) {
89                // @codeCoverageIgnoreStart
90                throw new UnexpectedValueException(
91                    'Encryption is not configured but OATHAuth is attempting to use encryption'
92                );
93                // @codeCoverageIgnoreEnd
94            }
95            $data['encrypted_secret'] = $data['secret'];
96            $data['secret'] = $encryptionHelper->decrypt( $data['secret'], $data['nonce'] );
97        } else {
98            $data['encrypted_secret'] = '';
99            $data['nonce'] = '';
100        }
101
102        return new static(
103            $data['id'] ?? null,
104            $data['friendly_name'] ?? null,
105            $data['created_timestamp'] ?? null,
106            $data['secret'] ?? '',
107            $data['encrypted_secret'],
108            $data['nonce']
109        );
110    }
111
112    public function __construct(
113        ?int $id,
114        ?string $friendlyName,
115        ?string $createdTimestamp,
116        string $secret,
117        string $encryptedSecret = '',
118        string $nonce = ''
119    ) {
120        parent::__construct( $id, $friendlyName, $createdTimestamp );
121        // Currently hardcoded values; might be used in the future
122        $this->secret = [
123            'mode' => 'hotp',
124            'secret' => $secret,
125            'period' => 30,
126            'algorithm' => 'SHA1',
127            'encrypted_secret' => $encryptedSecret,
128            'nonce' => $nonce
129        ];
130    }
131
132    public function getSecret(): string {
133        return $this->secret['secret'];
134    }
135
136    public function getEncryptedSecretAndNonce(): array {
137        return [
138            $this->secret['encrypted_secret'],
139            $this->secret['nonce'],
140        ];
141    }
142
143    public function verify( OATHUser $user, array $data ): bool {
144        global $wgOATHAuthWindowRadius;
145
146        $token = $data['token'] ?? '';
147
148        if ( $this->secret['mode'] !== 'hotp' ) {
149            // @codeCoverageIgnoreStart
150            throw new DomainException( 'OATHAuth extension does not support non-HOTP tokens' );
151            // @codeCoverageIgnoreEnd
152        }
153
154        // Prevent replay attacks
155        $services = MediaWikiServices::getInstance();
156        $store = $services->getMainObjectStash();
157
158        if ( $store instanceof EmptyBagOStuff ) {
159            // @codeCoverageIgnoreStart
160            // Try and find some usable cache if the MainObjectStash isn't useful
161            $store = $services->getObjectCacheFactory()->getLocalServerInstance( CACHE_ANYTHING );
162            // @codeCoverageIgnoreEnd
163        }
164
165        $key = $store->makeKey( 'oathauth-totp', 'usedtokens', $user->getCentralId() );
166        $lastWindow = (int)$store->get( $key );
167
168        $results = HOTP::generateByTimeWindow(
169            Base32::decode( self::addBase32Padding( $this->secret['secret'] ) ),
170            $this->secret['period'],
171            -$wgOATHAuthWindowRadius,
172            $wgOATHAuthWindowRadius,
173            (int)ConvertibleTimestamp::now( TimestampFormat::UNIX )
174        );
175
176        // Remove any whitespace from the received token, which can be an intended group separator
177        $token = preg_replace( '/\s+/', '', $token );
178
179        $clientIP = RequestContext::getMain()->getRequest()->getIP();
180
181        // Check to see if the user's given token is in the list of tokens generated
182        // for the time window.
183        foreach ( $results as $window => $result ) {
184            if ( $window <= $lastWindow || !hash_equals( $result->toHOTP( 6 ), $token ) ) {
185                continue;
186            }
187
188            $lastWindow = $window;
189
190            LoggerFactory::getInstance( 'authentication' )
191                ->info( 'OATHAuth user {user} entered a valid OTP from {clientip}', [
192                    'user' => $user->getAccount(),
193                    'clientip' => $clientIP,
194                ] );
195
196            $store->set(
197                $key,
198                $lastWindow,
199                $this->secret['period'] * ( 1 + 2 * $wgOATHAuthWindowRadius )
200            );
201
202            return true;
203        }
204
205        return false;
206    }
207
208    /** @inheritDoc */
209    public function getModule(): string {
210        return TOTP::MODULE_NAME;
211    }
212
213    public function jsonSerialize(): array {
214        $encryptionHelper = self::getEncryptionHelper();
215        if ( $encryptionHelper->isEnabled() ) {
216            $encryptedData = $this->getEncryptedSecretAndNonce();
217            if ( $this->forceReEncrypt || in_array( '', $encryptedData ) ) {
218                $data = $encryptionHelper->encrypt( $this->getSecret() );
219                $this->secret['encrypted_secret'] = $data['secret'];
220                $this->secret['nonce'] = $data['nonce'];
221            } else {
222                // Don't re-encrypt if unnecessary
223                $data = [
224                    'secret' => $encryptedData[0],
225                    'nonce' => $encryptedData[1]
226                ];
227            }
228
229            $data['format'] = 'encrypted';
230        } else {
231            $data = [
232                'secret' => $this->getSecret(),
233                'format' => 'unencrypted',
234            ];
235        }
236
237        $data['friendly_name'] = $this->getFriendlyName();
238        $data['version'] = self::VERSION;
239        return $data;
240    }
241
242    private static function getEncryptionHelper(): EncryptionHelper {
243        return OATHAuthServices::getInstance()->getEncryptionHelper();
244    }
245}