You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

551 lines
16 KiB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @author Joachim Bauch <mail@joachim-bauch.de>
  5. *
  6. * @license GNU AGPL version 3 or any later version
  7. *
  8. * This program is free software: you can redistribute it and/or modify
  9. * it under the terms of the GNU Affero General Public License as
  10. * published by the Free Software Foundation, either version 3 of the
  11. * License, or (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. *
  21. */
  22. namespace OCA\Talk;
  23. use OCA\Talk\Events\GetTurnServersEvent;
  24. use OCA\Talk\Model\Attendee;
  25. use OCA\Talk\Vendor\Firebase\JWT\JWT;
  26. use OCP\AppFramework\Utility\ITimeFactory;
  27. use OCP\EventDispatcher\IEventDispatcher;
  28. use OCP\IConfig;
  29. use OCP\IGroupManager;
  30. use OCP\IURLGenerator;
  31. use OCP\IUser;
  32. use OCP\IUserManager;
  33. use OCP\Security\ISecureRandom;
  34. use OCP\Server;
  35. class Config {
  36. public const SIGNALING_INTERNAL = 'internal';
  37. public const SIGNALING_EXTERNAL = 'external';
  38. public const SIGNALING_CLUSTER_CONVERSATION = 'conversation_cluster';
  39. public const SIGNALING_TICKET_V1 = 1;
  40. public const SIGNALING_TICKET_V2 = 2;
  41. protected IConfig $config;
  42. protected ITimeFactory $timeFactory;
  43. private IGroupManager $groupManager;
  44. private IUserManager $userManager;
  45. private IURLGenerator $urlGenerator;
  46. private ISecureRandom $secureRandom;
  47. private IEventDispatcher $dispatcher;
  48. protected array $canEnableSIP = [];
  49. public function __construct(IConfig $config,
  50. ISecureRandom $secureRandom,
  51. IGroupManager $groupManager,
  52. IUserManager $userManager,
  53. IURLGenerator $urlGenerator,
  54. ITimeFactory $timeFactory,
  55. IEventDispatcher $dispatcher) {
  56. $this->config = $config;
  57. $this->secureRandom = $secureRandom;
  58. $this->groupManager = $groupManager;
  59. $this->userManager = $userManager;
  60. $this->urlGenerator = $urlGenerator;
  61. $this->timeFactory = $timeFactory;
  62. $this->dispatcher = $dispatcher;
  63. }
  64. /**
  65. * @return string[]
  66. */
  67. public function getAllowedTalkGroupIds(): array {
  68. $groups = $this->config->getAppValue('spreed', 'allowed_groups', '[]');
  69. $groups = json_decode($groups, true);
  70. return \is_array($groups) ? $groups : [];
  71. }
  72. public function getUserReadPrivacy(string $userId): int {
  73. return (int) $this->config->getUserValue(
  74. $userId,
  75. 'spreed', 'read_status_privacy',
  76. (string) Participant::PRIVACY_PUBLIC);
  77. }
  78. /**
  79. * @return string[]
  80. */
  81. public function getSIPGroups(): array {
  82. $groups = $this->config->getAppValue('spreed', 'sip_bridge_groups', '[]');
  83. $groups = json_decode($groups, true);
  84. return \is_array($groups) ? $groups : [];
  85. }
  86. public function isSIPConfigured(): bool {
  87. return $this->getSIPSharedSecret() !== ''
  88. && $this->getDialInInfo() !== '';
  89. }
  90. /**
  91. * Determine if Talk federation is enabled on this instance
  92. */
  93. public function isFederationEnabled(): bool {
  94. // TODO: Set to default true once implementation is complete
  95. return $this->config->getAppValue('spreed', 'federation_enabled', 'no') === 'yes';
  96. }
  97. public function isBreakoutRoomsEnabled(): bool {
  98. return $this->config->getAppValue('spreed', 'breakout_rooms', 'yes') === 'yes';
  99. }
  100. public function getDialInInfo(): string {
  101. return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info');
  102. }
  103. public function getSIPSharedSecret(): string {
  104. return $this->config->getAppValue('spreed', 'sip_bridge_shared_secret');
  105. }
  106. public function canUserEnableSIP(IUser $user): bool {
  107. if (isset($this->canEnableSIP[$user->getUID()])) {
  108. return $this->canEnableSIP[$user->getUID()];
  109. }
  110. $this->canEnableSIP[$user->getUID()] = false;
  111. $allowedGroups = $this->getSIPGroups();
  112. if (empty($allowedGroups)) {
  113. $this->canEnableSIP[$user->getUID()] = true;
  114. } else {
  115. $userGroups = $this->groupManager->getUserGroupIds($user);
  116. $this->canEnableSIP[$user->getUID()] = !empty(array_intersect($allowedGroups, $userGroups));
  117. }
  118. return $this->canEnableSIP[$user->getUID()];
  119. }
  120. public function isRecordingEnabled(): bool {
  121. $isSignalingInternal = $this->getSignalingMode() === self::SIGNALING_INTERNAL;
  122. $recordingAllowed = $this->config->getAppValue('spreed', 'call_recording', 'yes') === 'yes';
  123. return !$isSignalingInternal && $recordingAllowed;
  124. }
  125. public function isDisabledForUser(IUser $user): bool {
  126. $allowedGroups = $this->getAllowedTalkGroupIds();
  127. if (empty($allowedGroups)) {
  128. return false;
  129. }
  130. $userGroups = $this->groupManager->getUserGroupIds($user);
  131. return empty(array_intersect($allowedGroups, $userGroups));
  132. }
  133. /**
  134. * @return string[]
  135. */
  136. public function getAllowedConversationsGroupIds(): array {
  137. $groups = $this->config->getAppValue('spreed', 'start_conversations', '[]');
  138. $groups = json_decode($groups, true);
  139. return \is_array($groups) ? $groups : [];
  140. }
  141. public function isNotAllowedToCreateConversations(IUser $user): bool {
  142. $allowedGroups = $this->getAllowedConversationsGroupIds();
  143. if (empty($allowedGroups)) {
  144. return false;
  145. }
  146. $userGroups = $this->groupManager->getUserGroupIds($user);
  147. return empty(array_intersect($allowedGroups, $userGroups));
  148. }
  149. public function getDefaultPermissions(): int {
  150. // Admin configured default permissions
  151. $configurableDefault = $this->config->getAppValue('spreed', 'default_permissions');
  152. if ($configurableDefault !== '') {
  153. return (int) $configurableDefault;
  154. }
  155. // Falling back to an unrestricted set of permissions, only ignoring the lobby is off
  156. return Attendee::PERMISSIONS_MAX_DEFAULT & ~Attendee::PERMISSIONS_LOBBY_IGNORE;
  157. }
  158. public function getAttachmentFolder(string $userId): string {
  159. return $this->config->getUserValue($userId, 'spreed', 'attachment_folder', '/Talk');
  160. }
  161. /**
  162. * @return string[]
  163. */
  164. public function getAllServerUrlsForCSP(): array {
  165. $urls = [];
  166. foreach ($this->getStunServers() as $server) {
  167. $urls[] = $server;
  168. }
  169. foreach ($this->getTurnServers() as $server) {
  170. $urls[] = $server['server'];
  171. }
  172. foreach ($this->getSignalingServers() as $server) {
  173. $urls[] = $this->getWebSocketDomainForSignalingServer($server['server']);
  174. }
  175. return $urls;
  176. }
  177. protected function getWebSocketDomainForSignalingServer(string $url): string {
  178. $url .= '/';
  179. if (strpos($url, 'https://') === 0) {
  180. return 'wss://' . substr($url, 8, strpos($url, '/', 9) - 8);
  181. }
  182. if (strpos($url, 'http://') === 0) {
  183. return 'ws://' . substr($url, 7, strpos($url, '/', 8) - 7);
  184. }
  185. if (strpos($url, 'wss://') === 0) {
  186. return substr($url, 0, strpos($url, '/', 7));
  187. }
  188. if (strpos($url, 'ws://') === 0) {
  189. return substr($url, 0, strpos($url, '/', 6));
  190. }
  191. $protocol = strpos($url, '://');
  192. if ($protocol !== false) {
  193. return substr($url, $protocol + 3, strpos($url, '/', $protocol + 3) - $protocol - 3);
  194. }
  195. return substr($url, 0, strpos($url, '/'));
  196. }
  197. /**
  198. * @return string[]
  199. */
  200. public function getStunServers(): array {
  201. $config = $this->config->getAppValue('spreed', 'stun_servers', json_encode(['stun.nextcloud.com:443']));
  202. $servers = json_decode($config, true);
  203. if (!is_array($servers) || empty($servers)) {
  204. $servers = ['stun.nextcloud.com:443'];
  205. }
  206. if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
  207. $servers = array_filter($servers, static function ($server) {
  208. return $server !== 'stun.nextcloud.com:443';
  209. });
  210. }
  211. return $servers;
  212. }
  213. /**
  214. * Generates a username and password for the TURN server
  215. *
  216. * @return array
  217. */
  218. public function getTurnServers(bool $withEvent = true): array {
  219. $config = $this->config->getAppValue('spreed', 'turn_servers');
  220. $servers = json_decode($config, true);
  221. if ($servers === null || empty($servers) || !is_array($servers)) {
  222. $servers = [];
  223. }
  224. if ($withEvent) {
  225. $event = new GetTurnServersEvent($servers);
  226. $this->dispatcher->dispatchTyped($event);
  227. $servers = $event->getServers();
  228. }
  229. foreach ($servers as $key => $server) {
  230. $servers[$key]['schemes'] = $server['schemes'] ?? 'turn';
  231. }
  232. return $servers;
  233. }
  234. /**
  235. * Prepares a list of TURN servers with username and password
  236. *
  237. * @return array
  238. */
  239. public function getTurnSettings(): array {
  240. $servers = $this->getTurnServers();
  241. if (empty($servers)) {
  242. return [];
  243. }
  244. // Credentials are valid for 24h
  245. // FIXME add the TTL to the response and properly reconnect then
  246. $timestamp = $this->timeFactory->getTime() + 86400;
  247. $rnd = $this->secureRandom->generate(16);
  248. $username = $timestamp . ':' . $rnd;
  249. foreach ($servers as $server) {
  250. $u = $server['username'] ?? $username;
  251. $password = $server['password'] ?? base64_encode(hash_hmac('sha1', $u, $server['secret'], true));
  252. $turnSettings[] = [
  253. 'schemes' => $server['schemes'],
  254. 'server' => $server['server'],
  255. 'username' => $u,
  256. 'password' => $password,
  257. 'protocols' => $server['protocols'],
  258. ];
  259. }
  260. return $turnSettings;
  261. }
  262. public function getSignalingMode(bool $cleanExternalSignaling = true): string {
  263. $validModes = [
  264. self::SIGNALING_INTERNAL,
  265. self::SIGNALING_EXTERNAL,
  266. self::SIGNALING_CLUSTER_CONVERSATION,
  267. ];
  268. $mode = $this->config->getAppValue('spreed', 'signaling_mode', null);
  269. if ($mode === self::SIGNALING_INTERNAL) {
  270. return self::SIGNALING_INTERNAL;
  271. }
  272. $numSignalingServers = count($this->getSignalingServers());
  273. if ($numSignalingServers === 0) {
  274. return self::SIGNALING_INTERNAL;
  275. }
  276. if ($numSignalingServers === 1
  277. && $cleanExternalSignaling
  278. && $this->config->getAppValue('spreed', 'signaling_dev', 'no') === 'no') {
  279. return self::SIGNALING_EXTERNAL;
  280. }
  281. return \in_array($mode, $validModes, true) ? $mode : self::SIGNALING_EXTERNAL;
  282. }
  283. /**
  284. * Returns list of signaling servers. Each entry contains the URL of the
  285. * server and a flag whether the certificate should be verified.
  286. *
  287. * @return array
  288. */
  289. public function getSignalingServers(): array {
  290. $config = $this->config->getAppValue('spreed', 'signaling_servers');
  291. $signaling = json_decode($config, true);
  292. if (!is_array($signaling) || !isset($signaling['servers'])) {
  293. return [];
  294. }
  295. return $signaling['servers'];
  296. }
  297. /**
  298. * @return string
  299. */
  300. public function getSignalingSecret(): string {
  301. $config = $this->config->getAppValue('spreed', 'signaling_servers');
  302. $signaling = json_decode($config, true);
  303. if (!is_array($signaling)) {
  304. return '';
  305. }
  306. return $signaling['secret'];
  307. }
  308. public function getHideSignalingWarning(): bool {
  309. return $this->config->getAppValue('spreed', 'hide_signaling_warning', 'no') === 'yes';
  310. }
  311. /**
  312. * @param int $version
  313. * @param string $userId
  314. * @return string
  315. */
  316. public function getSignalingTicket(int $version, ?string $userId): string {
  317. switch ($version) {
  318. case self::SIGNALING_TICKET_V1:
  319. return $this->getSignalingTicketV1($userId);
  320. case self::SIGNALING_TICKET_V2:
  321. return $this->getSignalingTicketV2($userId);
  322. default:
  323. return $this->getSignalingTicketV1($userId);
  324. }
  325. }
  326. /**
  327. * @param string $userId
  328. * @return string
  329. */
  330. private function getSignalingTicketV1(?string $userId): string {
  331. if (empty($userId)) {
  332. $secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
  333. } else {
  334. $secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
  335. }
  336. if (empty($secret)) {
  337. // Create secret lazily on first access.
  338. // TODO(fancycode): Is there a possibility for a race condition?
  339. $secret = $this->secureRandom->generate(255);
  340. if (empty($userId)) {
  341. $this->config->setAppValue('spreed', 'signaling_ticket_secret', $secret);
  342. } else {
  343. $this->config->setUserValue($userId, 'spreed', 'signaling_ticket_secret', $secret);
  344. }
  345. }
  346. // Format is "random:timestamp:userid:checksum" and "checksum" is the
  347. // SHA256-HMAC of "random:timestamp:userid" with the per-user secret.
  348. $random = $this->secureRandom->generate(16);
  349. $timestamp = $this->timeFactory->getTime();
  350. $data = $random . ':' . $timestamp . ':' . $userId;
  351. $hash = hash_hmac('sha256', $data, $secret);
  352. return $data . ':' . $hash;
  353. }
  354. private function ensureSignalingTokenKeys(string $alg): void {
  355. $secret = $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
  356. if ($secret) {
  357. return;
  358. }
  359. if (substr($alg, 0, 2) === 'ES') {
  360. $privKey = openssl_pkey_new([
  361. 'curve_name' => 'prime256v1',
  362. 'private_key_type' => OPENSSL_KEYTYPE_EC,
  363. ]);
  364. $pubKey = openssl_pkey_get_details($privKey);
  365. $public = $pubKey['key'];
  366. if (!openssl_pkey_export($privKey, $secret)) {
  367. throw new \Exception('Could not export private key');
  368. }
  369. } elseif (substr($alg, 0, 2) === 'RS') {
  370. $privKey = openssl_pkey_new([
  371. 'private_key_bits' => 2048,
  372. 'private_key_type' => OPENSSL_KEYTYPE_RSA,
  373. ]);
  374. $pubKey = openssl_pkey_get_details($privKey);
  375. $public = $pubKey['key'];
  376. if (!openssl_pkey_export($privKey, $secret)) {
  377. throw new \Exception('Could not export private key');
  378. }
  379. } elseif ($alg === 'EdDSA') {
  380. $privKey = sodium_crypto_sign_keypair();
  381. $public = base64_encode(sodium_crypto_sign_publickey($privKey));
  382. $secret = base64_encode(sodium_crypto_sign_secretkey($privKey));
  383. } else {
  384. throw new \Exception('Unsupported algorithm ' . $alg);
  385. }
  386. $this->config->setAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg), $secret);
  387. $this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $public);
  388. }
  389. public function getSignalingTokenAlgorithm(): string {
  390. return $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256');
  391. }
  392. public function getSignalingTokenPrivateKey(?string $alg = null): string {
  393. if (!$alg) {
  394. $alg = $this->getSignalingTokenAlgorithm();
  395. }
  396. $this->ensureSignalingTokenKeys($alg);
  397. return $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
  398. }
  399. public function getSignalingTokenPublicKey(?string $alg = null): string {
  400. if (!$alg) {
  401. $alg = $this->getSignalingTokenAlgorithm();
  402. }
  403. $this->ensureSignalingTokenKeys($alg);
  404. return $this->config->getAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg));
  405. }
  406. /**
  407. * @param IUser $user
  408. * @return array
  409. */
  410. public function getSignalingUserData(IUser $user): array {
  411. return [
  412. 'displayname' => $user->getDisplayName(),
  413. ];
  414. }
  415. /**
  416. * @param string $userId
  417. * @return string
  418. */
  419. private function getSignalingTicketV2(?string $userId): string {
  420. $timestamp = $this->timeFactory->getTime();
  421. $data = [
  422. 'iss' => $this->urlGenerator->getAbsoluteURL(''),
  423. 'iat' => $timestamp,
  424. 'exp' => $timestamp + 60, // Valid for 1 minute.
  425. ];
  426. $user = !empty($userId) ? $this->userManager->get($userId) : null;
  427. if ($user instanceof IUser) {
  428. $data['sub'] = $user->getUID();
  429. $data['userdata'] = $this->getSignalingUserData($user);
  430. }
  431. $alg = $this->getSignalingTokenAlgorithm();
  432. $secret = $this->getSignalingTokenPrivateKey($alg);
  433. $token = JWT::encode($data, $secret, $alg);
  434. return $token;
  435. }
  436. /**
  437. * @param string $userId
  438. * @param string $ticket
  439. * @return bool
  440. */
  441. public function validateSignalingTicket(?string $userId, string $ticket): bool {
  442. if (empty($userId)) {
  443. $secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
  444. } else {
  445. $secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
  446. }
  447. if (empty($secret)) {
  448. return false;
  449. }
  450. $lastColon = strrpos($ticket, ':');
  451. if ($lastColon === false) {
  452. // Immediately reject invalid formats.
  453. return false;
  454. }
  455. // TODO(fancycode): Should we reject tickets that are too old?
  456. $data = substr($ticket, 0, $lastColon);
  457. $hash = hash_hmac('sha256', $data, $secret);
  458. return hash_equals($hash, substr($ticket, $lastColon + 1));
  459. }
  460. public function getGridVideosLimit(): int {
  461. return (int) $this->config->getAppValue('spreed', 'grid_videos_limit', '19'); // 5*4 - self
  462. }
  463. public function getGridVideosLimitEnforced(): bool {
  464. return $this->config->getAppValue('spreed', 'grid_videos_limit_enforced', 'no') === 'yes';
  465. }
  466. }