Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.59% covered (danger)
19.59%
38 / 194
25.00% covered (danger)
25.00%
6 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebAuthnKey
19.59% covered (danger)
19.59%
38 / 194
25.00% covered (danger)
25.00%
6 / 24
788.82
0.00% covered (danger)
0.00%
0 / 1
 newKey
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 newFromData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 setDataFromEncodedDBData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 setPasswordlessSupport
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setFriendlyName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAttestedCredentialData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserHandle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSignCounter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSignCounter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAttestationType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTrustPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 verify
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 verifyRegistration
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTransports
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkFriendlyName
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 registrationCeremony
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
30
 authenticationCeremony
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
20
 getHost
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findOneByCredentialId
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getAttestationSupportManager
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getModule
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * @license GPL-2.0-or-later
5 */
6
7namespace MediaWiki\Extension\OATHAuth\Key;
8
9use Cose\Algorithm\Manager;
10use Cose\Algorithm\Signature\ECDSA\ES256;
11use Cose\Algorithm\Signature\ECDSA\ES512;
12use Cose\Algorithm\Signature\EdDSA\EdDSA;
13use Cose\Algorithm\Signature\RSA\RS1;
14use Cose\Algorithm\Signature\RSA\RS256;
15use Cose\Algorithm\Signature\RSA\RS512;
16use LogicException;
17use MediaWiki\Context\RequestContext;
18use MediaWiki\Extension\OATHAuth\AAGUIDLookup;
19use MediaWiki\Extension\OATHAuth\Module\WebAuthn;
20use MediaWiki\Extension\OATHAuth\OATHAuthServices;
21use MediaWiki\Extension\OATHAuth\OATHUser;
22use MediaWiki\Extension\OATHAuth\WebAuthnSerializerFactory;
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\Request\WebRequest;
25use Psr\Log\LoggerInterface;
26use Symfony\Component\Uid\Uuid;
27use Throwable;
28use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
29use Webauthn\AttestationStatement\AppleAttestationStatementSupport;
30use Webauthn\AttestationStatement\AttestationStatementSupportManager;
31use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
32use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
33use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
34use Webauthn\AttestedCredentialData;
35use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
36use Webauthn\AuthenticatorAssertionResponse;
37use Webauthn\AuthenticatorAssertionResponseValidator;
38use Webauthn\AuthenticatorAttestationResponse;
39use Webauthn\AuthenticatorAttestationResponseValidator;
40use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
41use Webauthn\PublicKeyCredential;
42use Webauthn\PublicKeyCredentialCreationOptions;
43use Webauthn\PublicKeyCredentialDescriptor;
44use Webauthn\PublicKeyCredentialRequestOptions;
45use Webauthn\PublicKeyCredentialSource;
46use Webauthn\TrustPath\EmptyTrustPath;
47use Webauthn\TrustPath\TrustPath;
48use Wikimedia\Timestamp\ConvertibleTimestamp;
49
50/**
51 * This holds the information on user's private key
52 * and does the actual authentication of data passed
53 * by the client with data saved on server
54 */
55class WebAuthnKey extends AuthKey {
56    private const MODE_CREATE = 'webauthn.create';
57    private const MODE_AUTHENTICATE = 'webauthn.authenticate';
58
59    /**
60     * User handle represents the unique ID of the user.
61     *
62     * It is a randomly generated 64-bit string.
63     *
64     * It can change if the user disables and then re-enables
65     * webauthn module, but MUST be the same for each key
66     * if the user has multiple keys set at once.
67     */
68    protected string $userHandle;
69
70    protected AttestedCredentialData $attestedCredentialData;
71
72    protected int $signCounter = 0;
73
74    protected string $credentialType = PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY;
75
76    protected string $credentialAttestationType = '';
77
78    protected TrustPath $credentialTrustPath;
79
80    protected LoggerInterface $logger;
81
82    protected array $credentialTransports = [];
83
84    /**
85     * Create a new empty key instance.
86     *
87     * Used for new keys.
88     */
89    public static function newKey(): self {
90        return new static(
91            null,
92            null,
93            null,
94            static::MODE_CREATE,
95            RequestContext::getMain()
96        );
97    }
98
99    /**
100     * Create a new key instance from given data.
101     *
102     * Used for existing keys.
103     */
104    public static function newFromData( array $data ): self {
105        $key = new static(
106            $data['id'] ?? null,
107            $data['friendlyName'] ?? null,
108            $data['created_timestamp'] ?? null,
109            static::MODE_AUTHENTICATE,
110            RequestContext::getMain()
111        );
112        $key->setDataFromEncodedDBData( $data );
113        return $key;
114    }
115
116    protected function __construct(
117        ?int $id,
118        ?string $friendlyName,
119        ?string $createdTimestamp,
120        protected string $mode,
121        protected RequestContext $context
122    ) {
123        parent::__construct( $id, $friendlyName, $createdTimestamp );
124        // There is no documentation on what this trust path is
125        // and how it should be used
126        $this->credentialTrustPath = new EmptyTrustPath();
127        $this->logger = LoggerFactory::getInstance( 'authentication' );
128    }
129
130    public function jsonSerialize(): array {
131        return [
132            "userHandle" => base64_encode( $this->userHandle ),
133            "publicKeyCredentialId" => base64_encode( $this->attestedCredentialData->credentialId ),
134            "credentialPublicKey" => base64_encode(
135                (string)$this->attestedCredentialData->credentialPublicKey
136            ),
137            "aaguid" => (string)$this->attestedCredentialData->aaguid,
138            "friendlyName" => $this->friendlyName,
139            "counter" => $this->signCounter,
140            "type" => $this->credentialType,
141            "transports" => $this->getTransports(),
142            "attestationType" => $this->credentialAttestationType,
143            "trustPath" => $this->credentialTrustPath,
144            "supportsPasswordless" => $this->supportsPasswordless
145        ];
146    }
147
148    /**
149     * Set the key up with data coming from DB
150     */
151    public function setDataFromEncodedDBData( array $data ): void {
152        $this->userHandle = base64_decode( $data['userHandle'] );
153        $this->signCounter = $data['counter'];
154        $this->credentialTransports = $data['transports'];
155        $this->supportsPasswordless = $data['supportsPasswordless'] ?? false;
156        $this->attestedCredentialData = new AttestedCredentialData(
157            Uuid::fromString( $data['aaguid'] ),
158            base64_decode( $data['publicKeyCredentialId'] ),
159            base64_decode( $data['credentialPublicKey'] )
160        );
161    }
162
163    public function setPasswordlessSupport( bool $supportsPasswordlessMode ): void {
164        $this->supportsPasswordless = $supportsPasswordlessMode;
165    }
166
167    /**
168     * Sets friendly name
169     * If value exists, it will be appended with a unique suffix
170     */
171    private function setFriendlyName( string $name ): void {
172        $this->friendlyName = trim( $name );
173        $this->checkFriendlyName();
174    }
175
176    public function getAttestedCredentialData(): AttestedCredentialData {
177        return $this->attestedCredentialData;
178    }
179
180    public function getUserHandle(): string {
181        return $this->userHandle;
182    }
183
184    public function getSignCounter(): int {
185        return $this->signCounter;
186    }
187
188    public function setSignCounter( int $newCount ): void {
189        $this->signCounter = $newCount;
190    }
191
192    public function getAttestationType(): string {
193        return $this->credentialAttestationType;
194    }
195
196    public function getTrustPath(): TrustPath {
197        return $this->credentialTrustPath;
198    }
199
200    public function verify( OATHUser $user, array $data ): bool {
201        if ( $this->mode !== static::MODE_AUTHENTICATE ) {
202            $this->logger->error( 'Authentication attempt by user {user} while not in authenticate mode', [
203                'user' => $user->getUser()->getName(),
204            ] );
205            throw new LogicException( 'WebAuthnKey::verify(): invalid mode' );
206        }
207        return $this->authenticationCeremony(
208            $data['credential'],
209            $data['authInfo'],
210            $user
211        );
212    }
213
214    public function verifyRegistration(
215        string $friendlyName,
216        string $data,
217        PublicKeyCredentialCreationOptions $registrationObject,
218        OATHUser $user
219    ): bool {
220        if ( $this->mode !== static::MODE_CREATE ) {
221            $this->logger->error( 'Registration attempt by user {user} while not in register mode', [
222                'user' => $user->getUser()->getName(),
223            ] );
224            throw new LogicException( 'WebAuthnKey::verifyRegistration(): invalid mode' );
225        }
226        return $this->registrationCeremony( $data, $registrationObject, $user, $friendlyName );
227    }
228
229    /**
230     * Get the credential type
231     */
232    public function getType(): string {
233        return $this->credentialType;
234    }
235
236    /**
237     * Get transports available for the credential assigned to this key
238     */
239    public function getTransports(): array {
240        return $this->credentialTransports;
241    }
242
243    private function checkFriendlyName(): void {
244        $repo = OATHAuthServices::getInstance()->getUserRepository();
245
246        $friendlyNames = [];
247        foreach ( WebAuthn::getWebAuthnKeys( $repo->findByUser( $this->context->getUser() ) ) as $key ) {
248            $friendlyName = $key->getFriendlyName();
249            if ( $friendlyName === null ) {
250                continue;
251            }
252            $friendlyNames[] = strtolower( $friendlyName );
253        }
254
255        $original = $this->friendlyName;
256        $inc = 2;
257        while ( in_array( strtolower( $this->friendlyName ), $friendlyNames ) ) {
258            $this->friendlyName = "$original #$inc";
259            $inc++;
260        }
261    }
262
263    private function registrationCeremony(
264        string $data,
265        PublicKeyCredentialCreationOptions $registrationObject,
266        OATHUser $user,
267        string $friendlyName = ''
268    ): bool {
269        try {
270            $attestationStatementSupportManager = self::getAttestationSupportManager();
271
272            $serializer = ( new WebAuthnSerializerFactory( $attestationStatementSupportManager ) )->create();
273            $publicKeyCredential = $serializer->deserialize(
274                $data,
275                PublicKeyCredential::class,
276                'json',
277            );
278
279            $response = $publicKeyCredential->response;
280
281            if ( !$response instanceof AuthenticatorAttestationResponse ) {
282                return false;
283            }
284
285            $stepManagerFactory = new CeremonyStepManagerFactory();
286            $stepManagerFactory->setAttestationStatementSupportManager( $attestationStatementSupportManager );
287            $stepManagerFactory->setExtensionOutputCheckerHandler( new ExtensionOutputCheckerHandler() );
288
289            $authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
290                ceremonyStepManager: $stepManagerFactory->creationCeremony()
291            );
292
293            $authenticatorAttestationResponseValidator->setLogger( $this->logger );
294
295            $authenticatorAttestationResponseValidator->check(
296                $response,
297                $registrationObject,
298                $this->getHost( $this->context->getRequest() ),
299            );
300        } catch ( Throwable $ex ) {
301            $this->logger->warning(
302                "WebAuthn key registration failed due to: {$ex->getMessage()}"
303            );
304            return false;
305        }
306
307        if ( $response->attestationObject->authData->hasAttestedCredentialData() ) {
308            $this->userHandle = $registrationObject->user->id;
309            $this->attestedCredentialData = $response->attestationObject
310                ->authData->attestedCredentialData;
311            $this->signCounter = $response->attestationObject->authData->signCount;
312            $this->credentialTransports = $response->transports;
313
314            if ( trim( $friendlyName ) === '' ) {
315                $aaguid = (string)$this->attestedCredentialData->aaguid;
316                $friendlyName = AAGUIDLookup::generateFriendlyName( $aaguid );
317            }
318            $this->setFriendlyName( $friendlyName );
319
320            $this->logger->info(
321                "User {$user->getUser()->getName()} registered new WebAuthn key"
322            );
323            return true;
324        }
325        $this->logger->warning(
326            'WebAuthn key registration failed due to: No AttestedCredentialData in the response'
327        );
328        return false;
329    }
330
331    private function authenticationCeremony(
332        string $data,
333        PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
334        OATHUser $user
335    ): bool {
336        try {
337            $serializer = ( new WebAuthnSerializerFactory( self::getAttestationSupportManager() ) )->create();
338            $publicKeyCredential = $serializer->deserialize(
339                $data,
340                PublicKeyCredential::class,
341                'json',
342            );
343
344            $response = $publicKeyCredential->response;
345            if ( !$response instanceof AuthenticatorAssertionResponse ) {
346                return false;
347            }
348
349            $pubKeySource = $this->findOneByCredentialId( $user, $publicKeyCredential->rawId );
350            if ( $pubKeySource === null ) {
351                return false;
352            }
353
354            $coseAlgorithmManager = new Manager();
355            $coseAlgorithmManager->add(
356                new ES256(),
357                new ES512(),
358                new EdDSA(),
359                new RS1(),
360                new RS256(),
361                new RS512()
362            );
363
364            $stepManagerFactory = new CeremonyStepManagerFactory();
365            $stepManagerFactory->setExtensionOutputCheckerHandler( new ExtensionOutputCheckerHandler() );
366            $stepManagerFactory->setAlgorithmManager( $coseAlgorithmManager );
367
368            $authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
369                ceremonyStepManager: $stepManagerFactory->requestCeremony(),
370            );
371            $authenticatorAssertionResponseValidator->setLogger( $this->logger );
372
373            // Check the response against the attestation request
374            $authenticatorAssertionResponseValidator->check(
375                $pubKeySource,
376                $publicKeyCredential->response,
377                $publicKeyCredentialRequestOptions,
378                $this->getHost( $this->context->getRequest() ),
379                $this->userHandle,
380            );
381            return true;
382        } catch ( Throwable $ex ) {
383            $this->logger->warning( 'WebAuthn authentication failed due to: {message}', [
384                'message' => $ex->getMessage(),
385                'exception' => $ex,
386                'user' => $user->getUser()->getName(),
387            ] );
388            return false;
389        }
390    }
391
392    private function getHost( WebRequest $request ): string {
393        return parse_url( $request->getFullRequestURL(), PHP_URL_HOST );
394    }
395
396    private function findOneByCredentialId(
397        OATHUser $user,
398        string $publicKeyCredentialId
399    ): ?PublicKeyCredentialSource {
400        foreach ( WebAuthn::getWebAuthnKeys( $user ) as $key ) {
401            if ( $key->getAttestedCredentialData()->credentialId !== $publicKeyCredentialId ) {
402                continue;
403            }
404
405            return PublicKeyCredentialSource::create(
406                publicKeyCredentialId: $key->getAttestedCredentialData()->credentialId,
407                type: $key->getType(),
408                transports: $key->getTransports(),
409                attestationType: $key->getAttestationType(),
410                trustPath: $key->getTrustPath(),
411                aaguid: $key->getAttestedCredentialData()->aaguid,
412                credentialPublicKey: (string)$key->getAttestedCredentialData()->credentialPublicKey,
413                userHandle: $key->getUserHandle(),
414                counter: $key->getSignCounter(),
415            );
416        }
417
418        return null;
419    }
420
421    public static function getAttestationSupportManager(): AttestationStatementSupportManager {
422        return new AttestationStatementSupportManager( [
423            // FIXME supporting all these formats probably doesn't do much good as long as we
424            //  set the attestation conveyance preference to 'none' in WebAuthnAuthenticator::getRegisterInfo()
425            new FidoU2FAttestationStatementSupport(),
426            new PackedAttestationStatementSupport( new Manager() ),
427            new AndroidKeyAttestationStatementSupport(),
428            new AppleAttestationStatementSupport(),
429            new TPMAttestationStatementSupport( ConvertibleTimestamp::getClock() ),
430        ] );
431    }
432
433    /** @inheritDoc */
434    public function getModule(): string {
435        return WebAuthn::MODULE_ID;
436    }
437}