From b7bf8ec3c59a6eae72e47bc85d7349c73667f3c0 Mon Sep 17 00:00:00 2001
From: S1m <git@sgougeon.fr>
Date: Sun, 24 Mar 2024 12:17:10 +0100
Subject: [PATCH] feat(webauthn): Add user verification to webauthn challenges

Require user verification if all tokens are registered

with UV flag, else discourage it

Signed-off-by: S1m <git@sgougeon.fr>
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
---
 .../Version30000Date20240815080800.php        | 27 +++++++++++++++++++
 lib/composer/composer/autoload_classmap.php   |  1 +
 lib/composer/composer/autoload_static.php     |  1 +
 .../WebAuthn/CredentialRepository.php         |  9 +++++--
 .../WebAuthn/Db/PublicKeyCredentialEntity.php | 11 +++++++-
 .../Authentication/WebAuthn/Manager.php       | 15 +++++++----
 version.php                                   |  2 +-
 7 files changed, 57 insertions(+), 9 deletions(-)
 create mode 100644 core/Migrations/Version30000Date20240815080800.php

diff --git a/core/Migrations/Version30000Date20240815080800.php b/core/Migrations/Version30000Date20240815080800.php
new file mode 100644
index 0000000000000..c65cd6830c753
--- /dev/null
+++ b/core/Migrations/Version30000Date20240815080800.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 S1m <git@sgougeon.fr>
+ * SPDX-FileCopyrightText: 2024 Richard Steinmetz <richard@steinmetz.cloud>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Core\Migrations;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version30000Date20240815080800 extends SimpleMigrationStep {
+	public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+		/** @var ISchemaWrapper $schema */
+		$schema = $schemaClosure();
+
+		$table = $schema->getTable('webauthn');
+		$table->addColumn('user_verification', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
+		return $schema;
+	}
+}
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 7c44d95422349..b58b51e8cbf6d 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -1365,6 +1365,7 @@
     'OC\\Core\\Migrations\\Version30000Date20240429122720' => $baseDir . '/core/Migrations/Version30000Date20240429122720.php',
     'OC\\Core\\Migrations\\Version30000Date20240708160048' => $baseDir . '/core/Migrations/Version30000Date20240708160048.php',
     'OC\\Core\\Migrations\\Version30000Date20240717111406' => $baseDir . '/core/Migrations/Version30000Date20240717111406.php',
+    'OC\\Core\\Migrations\\Version30000Date20240815080800' => $baseDir . '/core/Migrations/Version30000Date20240815080800.php',
     'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
     'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
     'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 63fa40d39bf78..c0e18224e9cfe 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -1398,6 +1398,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
         'OC\\Core\\Migrations\\Version30000Date20240429122720' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240429122720.php',
         'OC\\Core\\Migrations\\Version30000Date20240708160048' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240708160048.php',
         'OC\\Core\\Migrations\\Version30000Date20240717111406' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240717111406.php',
+        'OC\\Core\\Migrations\\Version30000Date20240815080800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240815080800.php',
         'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
         'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
         'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',
diff --git a/lib/private/Authentication/WebAuthn/CredentialRepository.php b/lib/private/Authentication/WebAuthn/CredentialRepository.php
index f32136f9594c9..203f2ef902011 100644
--- a/lib/private/Authentication/WebAuthn/CredentialRepository.php
+++ b/lib/private/Authentication/WebAuthn/CredentialRepository.php
@@ -44,7 +44,7 @@ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCre
 		}, $entities);
 	}
 
-	public function saveAndReturnCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, ?string $name = null): PublicKeyCredentialEntity {
+	public function saveAndReturnCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, ?string $name = null, bool $userVerification = false): PublicKeyCredentialEntity {
 		$oldEntity = null;
 
 		try {
@@ -58,13 +58,18 @@ public function saveAndReturnCredentialSource(PublicKeyCredentialSource $publicK
 			$name = 'default';
 		}
 
-		$entity = PublicKeyCredentialEntity::fromPublicKeyCrendentialSource($name, $publicKeyCredentialSource);
+		$entity = PublicKeyCredentialEntity::fromPublicKeyCrendentialSource($name, $publicKeyCredentialSource, $userVerification);
 
 		if ($oldEntity) {
 			$entity->setId($oldEntity->getId());
 			if ($defaultName) {
 				$entity->setName($oldEntity->getName());
 			}
+
+			// Don't downgrade UV just because it was skipped during a login due to another key
+			if ($oldEntity->getUserVerification()) {
+				$entity->setUserVerification(true);
+			}
 		}
 
 		return $this->credentialMapper->insertOrUpdate($entity);
diff --git a/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php
index 443a7985cae83..6c4bc3ca81b6c 100644
--- a/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php
+++ b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php
@@ -23,6 +23,10 @@
  * @method void setPublicKeyCredentialId(string $id);
  * @method string getData();
  * @method void setData(string $data);
+ *
+ * @since 30.0.0 Add userVerification attribute
+ * @method bool|null getUserVerification();
+ * @method void setUserVerification(bool $userVerification);
  */
 class PublicKeyCredentialEntity extends Entity implements JsonSerializable {
 	/** @var string */
@@ -37,20 +41,25 @@ class PublicKeyCredentialEntity extends Entity implements JsonSerializable {
 	/** @var string */
 	protected $data;
 
+	/** @var bool|null */
+	protected $userVerification;
+
 	public function __construct() {
 		$this->addType('name', 'string');
 		$this->addType('uid', 'string');
 		$this->addType('publicKeyCredentialId', 'string');
 		$this->addType('data', 'string');
+		$this->addType('userVerification', 'boolean');
 	}
 
-	public static function fromPublicKeyCrendentialSource(string $name, PublicKeyCredentialSource $publicKeyCredentialSource): PublicKeyCredentialEntity {
+	public static function fromPublicKeyCrendentialSource(string $name, PublicKeyCredentialSource $publicKeyCredentialSource, bool $userVerification): PublicKeyCredentialEntity {
 		$publicKeyCredentialEntity = new self();
 
 		$publicKeyCredentialEntity->setName($name);
 		$publicKeyCredentialEntity->setUid($publicKeyCredentialSource->getUserHandle());
 		$publicKeyCredentialEntity->setPublicKeyCredentialId(base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId()));
 		$publicKeyCredentialEntity->setData(json_encode($publicKeyCredentialSource));
+		$publicKeyCredentialEntity->setUserVerification($userVerification);
 
 		return $publicKeyCredentialEntity;
 	}
diff --git a/lib/private/Authentication/WebAuthn/Manager.php b/lib/private/Authentication/WebAuthn/Manager.php
index 007be24599257..7aa7a3c8f3a20 100644
--- a/lib/private/Authentication/WebAuthn/Manager.php
+++ b/lib/private/Authentication/WebAuthn/Manager.php
@@ -88,8 +88,8 @@ public function startRegistration(IUser $user, string $serverHost): PublicKeyCre
 		];
 
 		$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(
-			null,
-			AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
+			AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
+			AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED,
 			null,
 			false,
 		);
@@ -151,7 +151,8 @@ public function finishRegister(PublicKeyCredentialCreationOptions $publicKeyCred
 		}
 
 		// Persist the data
-		return $this->repository->saveAndReturnCredentialSource($publicKeyCredentialSource, $name);
+		$userVerification = $response->attestationObject->authData->isUserVerified();
+		return $this->repository->saveAndReturnCredentialSource($publicKeyCredentialSource, $name, $userVerification);
 	}
 
 	private function stripPort(string $serverHost): string {
@@ -160,7 +161,11 @@ private function stripPort(string $serverHost): string {
 
 	public function startAuthentication(string $uid, string $serverHost): PublicKeyCredentialRequestOptions {
 		// List of registered PublicKeyCredentialDescriptor classes associated to the user
-		$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) {
+		$userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED;
+		$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) use (&$userVerificationRequirement) {
+			if ($entity->getUserVerification() !== true) {
+				$userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED;
+			}
 			$credential = $entity->toPublicKeyCredentialSource();
 			return new PublicKeyCredentialDescriptor(
 				$credential->type,
@@ -173,7 +178,7 @@ public function startAuthentication(string $uid, string $serverHost): PublicKeyC
 			random_bytes(32),                                                          // Challenge
 			$this->stripPort($serverHost),                                             // Relying Party ID
 			$registeredPublicKeyCredentialDescriptors,                                 // Registered PublicKeyCredentialDescriptor classes
-			AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
+			$userVerificationRequirement,
 			60000,                                                                     // Timeout
 		);
 	}
diff --git a/version.php b/version.php
index e10c7a3985b53..db2f25a6a7d64 100644
--- a/version.php
+++ b/version.php
@@ -9,7 +9,7 @@
 // between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level
 // when updating major/minor version number.
 
-$OC_Version = [30, 0, 0, 8];
+$OC_Version = [30, 0, 0, 9];
 
 // The human-readable string
 $OC_VersionString = '30.0.0 RC1';
