diff options
-rw-r--r-- | docs/capabilities.md | 1 | ||||
-rw-r--r-- | docs/internal-signaling.md | 1 | ||||
-rw-r--r-- | lib/Capabilities.php | 5 | ||||
-rw-r--r-- | lib/Config.php | 124 | ||||
-rw-r--r-- | lib/Controller/SignalingController.php | 20 | ||||
-rw-r--r-- | lib/Signaling/Manager.php | 3 | ||||
-rw-r--r-- | src/utils/signaling.js | 58 | ||||
-rw-r--r-- | src/utils/webrtc/index.js | 6 | ||||
-rw-r--r-- | tests/php/CapabilitiesTest.php | 17 | ||||
-rw-r--r-- | tests/php/ConfigTest.php | 214 | ||||
-rw-r--r-- | tests/php/Controller/SignalingControllerTest.php | 25 | ||||
-rw-r--r-- | tests/php/Signaling/BackendNotifierTest.php | 3 |
12 files changed, 401 insertions, 76 deletions
diff --git a/docs/capabilities.md b/docs/capabilities.md index 311ffbcc1..9659e7428 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -104,3 +104,4 @@ title: Capabilities * `talk-polls` - Polls feature is available to use in the chat * `config => call => enabled` - Whether calling is enabled on the instance or not * `config => signaling => session-ping-limit` - Number of sessions the HPB is allowed to ping in the same request +* `config => signaling => hello-v2-token-key` - Public key to use when verifying JWT auth tokens for hello V2. diff --git a/docs/internal-signaling.md b/docs/internal-signaling.md index fc75d4e29..000cb7c31 100644 --- a/docs/internal-signaling.md +++ b/docs/internal-signaling.md @@ -26,6 +26,7 @@ | `stunservers` | array | v3 | STUN servers | | `turnservers` | array | v3 | TURN servers | | `sipDialinInfo` | string | v2 | Generic SIP dial-in information for this conversation (admin free text containing the phone number etc) | +| `helloAuthParams` | array | v3 | Parameters of the different `hello` versions for the external signaling server. | - STUN server diff --git a/lib/Capabilities.php b/lib/Capabilities.php index f0a84e74e..c388347be 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -150,6 +150,11 @@ class Capabilities implements IPublicCapability { $capabilities['config']['conversations']['can-create'] = $user instanceof IUser && !$this->talkConfig->isNotAllowedToCreateConversations($user); + $pubKey = $this->talkConfig->getSignalingTokenPublicKey(); + if ($pubKey) { + $capabilities['config']['signaling']['hello-v2-token-key'] = $pubKey; + } + if ($this->serverConfig->getAppValue('spreed', 'has_reference_id', 'no') === 'yes') { $capabilities['features'][] = 'chat-reference-id'; } diff --git a/lib/Config.php b/lib/Config.php index 1628aa1cd..b3dda8d85 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -23,12 +23,16 @@ declare(strict_types=1); namespace OCA\Talk; +use Firebase\JWT\JWT; + use OCP\AppFramework\Utility\ITimeFactory; use OCA\Talk\Events\GetTurnServersEvent; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IGroupManager; +use OCP\IURLGenerator; use OCP\IUser; +use OCP\IUserManager; use OCP\Security\ISecureRandom; class Config { @@ -36,9 +40,14 @@ class Config { public const SIGNALING_EXTERNAL = 'external'; public const SIGNALING_CLUSTER_CONVERSATION = 'conversation_cluster'; + public const SIGNALING_TICKET_V1 = 1; + public const SIGNALING_TICKET_V2 = 2; + protected IConfig $config; protected ITimeFactory $timeFactory; private IGroupManager $groupManager; + private IUserManager $userManager; + private IURLGenerator $urlGenerator; private ISecureRandom $secureRandom; private IEventDispatcher $dispatcher; @@ -47,11 +56,15 @@ class Config { public function __construct(IConfig $config, ISecureRandom $secureRandom, IGroupManager $groupManager, + IUserManager $userManager, + IURLGenerator $urlGenerator, ITimeFactory $timeFactory, IEventDispatcher $dispatcher) { $this->config = $config; $this->secureRandom = $secureRandom; $this->groupManager = $groupManager; + $this->userManager = $userManager; + $this->urlGenerator = $urlGenerator; $this->timeFactory = $timeFactory; $this->dispatcher = $dispatcher; } @@ -340,10 +353,26 @@ class Config { } /** + * @param int $version * @param string $userId * @return string */ - public function getSignalingTicket(?string $userId): string { + public function getSignalingTicket(int $version, ?string $userId): string { + switch ($version) { + case self::SIGNALING_TICKET_V1: + return $this->getSignalingTicketV1($userId); + case self::SIGNALING_TICKET_V2: + return $this->getSignalingTicketV2($userId); + default: + return $this->getSignalingTicketV1($userId); + } + } + + /** + * @param string $userId + * @return string + */ + private function getSignalingTicketV1(?string $userId): string { if (empty($userId)) { $secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret'); } else { @@ -369,6 +398,99 @@ class Config { return $data . ':' . $hash; } + private function ensureSignalingTokenKeys(string $alg): void { + $secret = $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg)); + if ($secret) { + return; + } + + if (substr($alg, 0, 2) === 'ES') { + $privKey = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + $pubKey = openssl_pkey_get_details($privKey); + $public = $pubKey['key']; + if (!openssl_pkey_export($privKey, $secret)) { + throw new \Exception('Could not export private key'); + } + } elseif (substr($alg, 0, 2) === 'RS') { + $privKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + $pubKey = openssl_pkey_get_details($privKey); + $public = $pubKey['key']; + if (!openssl_pkey_export($privKey, $secret)) { + throw new \Exception('Could not export private key'); + } + } elseif ($alg === 'EdDSA') { + $privKey = sodium_crypto_sign_keypair(); + $public = base64_encode(sodium_crypto_sign_publickey($privKey)); + $secret = base64_encode(sodium_crypto_sign_secretkey($privKey)); + } else { + throw new \Exception('Unsupported algorithm ' . $alg); + } + + $this->config->setAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg), $secret); + $this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $public); + } + + public function getSignalingTokenAlgorithm(): string { + return $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256'); + } + + public function getSignalingTokenPrivateKey(?string $alg = null): string { + if (!$alg) { + $alg = $this->getSignalingTokenAlgorithm(); + } + $this->ensureSignalingTokenKeys($alg); + + return $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg)); + } + + public function getSignalingTokenPublicKey(?string $alg = null): string { + if (!$alg) { + $alg = $this->getSignalingTokenAlgorithm(); + } + $this->ensureSignalingTokenKeys($alg); + + return $this->config->getAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg)); + } + + /** + * @param IUser $user + * @return array + */ + public function getSignalingUserData(IUser $user): array { + return [ + 'displayname' => $user->getDisplayName(), + ]; + } + + /** + * @param string $userId + * @return string + */ + private function getSignalingTicketV2(?string $userId): string { + $timestamp = $this->timeFactory->getTime(); + $data = [ + 'iss' => $this->urlGenerator->getAbsoluteURL(''), + 'iat' => $timestamp, + 'exp' => $timestamp + 60, // Valid for 1 minute. + ]; + $user = !empty($userId) ? $this->userManager->get($userId) : null; + if ($user instanceof IUser) { + $data['sub'] = $user->getUID(); + $data['userdata'] = $this->getSignalingUserData($user); + } + + $alg = $this->getSignalingTokenAlgorithm(); + $secret = $this->getSignalingTokenPrivateKey($alg); + $token = JWT::encode($data, $secret, $alg); + return $token; + } + /** * @param string $userId * @param string $ticket diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php index 40a2ac3e5..20954020f 100644 --- a/lib/Controller/SignalingController.php +++ b/lib/Controller/SignalingController.php @@ -46,6 +46,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use OCP\Http\Client\IClientService; +use OCP\IConfig; use OCP\IDBConnection; use OCP\IRequest; use OCP\IUser; @@ -59,6 +60,7 @@ class SignalingController extends OCSController { public const EVENT_BACKEND_SIGNALING_ROOMS = self::class . '::signalingBackendRoom'; + private IConfig $serverConfig; private Config $talkConfig; private \OCA\Talk\Signaling\Manager $signalingManager; private TalkSession $session; @@ -76,6 +78,7 @@ class SignalingController extends OCSController { public function __construct(string $appName, IRequest $request, + IConfig $serverConfig, Config $talkConfig, \OCA\Talk\Signaling\Manager $signalingManager, TalkSession $session, @@ -91,6 +94,7 @@ class SignalingController extends OCSController { LoggerInterface $logger, ?string $UserId) { parent::__construct($appName, $request); + $this->serverConfig = $serverConfig; $this->talkConfig = $talkConfig; $this->signalingManager = $signalingManager; $this->session = $session; @@ -160,12 +164,22 @@ class SignalingController extends OCSController { $signalingMode = $this->talkConfig->getSignalingMode(); $signaling = $this->signalingManager->getSignalingServerLinkForConversation($room); + $helloAuthParams = [ + '1.0' => [ + 'userid' => $this->userId, + 'ticket' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId), + ], + '2.0' => [ + 'token' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V2, $this->userId), + ], + ]; $data = [ 'signalingMode' => $signalingMode, 'userId' => $this->userId, 'hideWarning' => $signaling !== '' || $this->talkConfig->getHideSignalingWarning(), 'server' => $signaling, - 'ticket' => $this->talkConfig->getSignalingTicket($this->userId), + 'ticket' => $helloAuthParams['1.0']['ticket'], + 'helloAuthParams' => $helloAuthParams, 'stunservers' => $stun, 'turnservers' => $turn, 'sipDialinInfo' => $this->talkConfig->isSIPConfigured() ? $this->talkConfig->getDialInInfo() : '', @@ -546,9 +560,7 @@ class SignalingController extends OCSController { ]; if (!empty($userId)) { $response['auth']['userid'] = $user->getUID(); - $response['auth']['user'] = [ - 'displayname' => $user->getDisplayName(), - ]; + $response['auth']['user'] = $this->talkConfig->getSignalingUserData($user); } $this->logger->debug('Validated signaling ticket for {user}', [ 'user' => !empty($userId) ? $userId : '(guests)', diff --git a/lib/Signaling/Manager.php b/lib/Signaling/Manager.php index b69561343..87f472900 100644 --- a/lib/Signaling/Manager.php +++ b/lib/Signaling/Manager.php @@ -54,7 +54,8 @@ class Manager { $features = explode(',', $featureHeader); $features = array_map('trim', $features); return in_array('audio-video-permissions', $features, true) - && in_array('incall-all', $features, true); + && in_array('incall-all', $features, true) + && in_array('hello-v2', $features, true); } public function getSignalingServerLinkForConversation(?Room $room): string { diff --git a/src/utils/signaling.js b/src/utils/signaling.js index 6c711961d..56c98faaf 100644 --- a/src/utils/signaling.js +++ b/src/utils/signaling.js @@ -590,6 +590,7 @@ function Standalone(settings, urls) { url = url.slice(0, -1) } this.url = url + '/spreed' + this.welcomeTimeoutMs = 3000 this.initialReconnectIntervalMs = 1000 this.maxReconnectIntervalMs = 16000 this.reconnectIntervalMs = this.initialReconnectIntervalMs @@ -658,7 +659,11 @@ Signaling.Standalone.prototype.connect = function() { this.signalingConnectionWarning = null } this.reconnectIntervalMs = this.initialReconnectIntervalMs - this.sendHello() + if (this.settings.helloAuthParams['2.0']) { + this.waitForWelcomeTimeout = setTimeout(this.welcomeTimeout.bind(this), this.welcomeTimeoutMs) + } else { + this.sendHello() + } }.bind(this) this.socket.onerror = function(event) { console.error('Error', event) @@ -713,6 +718,9 @@ Signaling.Standalone.prototype.connect = function() { this._trigger('onBeforeReceiveMessage', [data]) const message = {} switch (data.type) { + case 'welcome': + this.welcomeReceived(data) + break case 'hello': if (!id) { // Only process if not received as result of our "hello". @@ -744,10 +752,17 @@ Signaling.Standalone.prototype.connect = function() { this._trigger('message', [message]) break case 'error': - if (data.error.code === 'processing_failed') { + switch (data.error.code) { + case 'processing_failed': console.error('An error occurred processing the signaling message, please ask your server administrator to check the log file') - } else { + break + case 'token_expired': + console.info('The signaling token is expired, need to update settings') + this._trigger('updateSettings') + break + default: console.error('Ignore unknown error', data) + break } break default: @@ -760,6 +775,30 @@ Signaling.Standalone.prototype.connect = function() { }.bind(this) } +Signaling.Standalone.prototype.welcomeReceived = function(data) { + console.debug('Welcome received', data) + if (this.waitForWelcomeTimeout !== null) { + clearTimeout(this.waitForWelcomeTimeout) + this.waitForWelcomeTimeout = null + } + + this.features = {} + let i + if (data.welcome && data.welcome.features) { + const features = data.welcome.features + for (i = 0; i < features.length; i++) { + this.features[features[i]] = true + } + } + + this.sendHello() +} + +Signaling.Standalone.prototype.welcomeTimeout = function() { + console.warn('No welcome received, assuming old-style signaling server') + this.sendHello() +} + Signaling.Standalone.prototype.sendBye = function() { if (this.connected) { this.doSend({ @@ -901,16 +940,19 @@ Signaling.Standalone.prototype.sendHello = function() { // Already reconnected with a new session. this._forceReconnect = false const url = generateOcsUrl('apps/spreed/api/v3/signaling/backend') + let helloVersion + if (this.hasFeature('hello-v2') && this.settings.helloAuthParams['2.0']) { + helloVersion = '2.0' + } else { + helloVersion = '1.0' + } msg = { type: 'hello', hello: { - version: '1.0', + version: helloVersion, auth: { url, - params: { - userid: this.settings.userId, - ticket: this.settings.ticket, - }, + params: this.settings.helloAuthParams[helloVersion], }, }, } diff --git a/src/utils/webrtc/index.js b/src/utils/webrtc/index.js index 5840e3795..2f2db261f 100644 --- a/src/utils/webrtc/index.js +++ b/src/utils/webrtc/index.js @@ -101,6 +101,12 @@ async function connectSignaling(token) { if (!signaling) { signaling = Signaling.createConnection(settings) + signaling.on('updateSettings', async function() { + const settings = await getSignalingSettings(token) + console.debug('Received updated settings', settings) + signaling.settings = settings + }) + } tokensInSignaling[token] = true diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 39017081b..3c050c089 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -316,4 +316,21 @@ class CapabilitiesTest extends TestCase { $this->assertInstanceOf(IPublicCapability::class, $capabilities); $this->assertSame([], $capabilities->getCapabilities()); } + + public function testCapabilitiesHelloV2Key(): void { + $capabilities = new Capabilities( + $this->serverConfig, + $this->talkConfig, + $this->commentsManager, + $this->userSession, + $this->appManager + ); + + $this->talkConfig->expects($this->once()) + ->method('getSignalingTokenPublicKey') + ->willReturn('this-is-the-key'); + + $data = $capabilities->getCapabilities(); + $this->assertEquals('this-is-the-key', $data['spreed']['config']['signaling']['hello-v2-token-key']); + } } diff --git a/tests/php/ConfigTest.php b/tests/php/ConfigTest.php index 80fed760e..569d89177 100644 --- a/tests/php/ConfigTest.php +++ b/tests/php/ConfigTest.php @@ -20,6 +20,9 @@ */ namespace OCA\Talk\Tests\php; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + use OCA\Talk\Config; use OCA\Talk\Events\GetTurnServersEvent; use OCA\Talk\Tests\php\Mocks\GetTurnServerListener; @@ -27,25 +30,38 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; use OCP\Security\ISecureRandom; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; class ConfigTest extends TestCase { - public function testGetStunServers() { - $servers = [ - 'stun1.example.com:443', - 'stun2.example.com:129', - ]; - + private function createConfig(IConfig $config) { /** @var MockObject|ITimeFactory $timeFactory */ $timeFactory = $this->createMock(ITimeFactory::class); /** @var MockObject|ISecureRandom $secureRandom */ $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ + /** @var MockObject|IGroupManager $groupManager */ $groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|IUserManager $userManager */ + $userManager = $this->createMock(IUserManager::class); + /** @var MockObject|IURLGenerator $urlGenerator */ + $urlGenerator = $this->createMock(IURLGenerator::class); /** @var MockObject|IEventDispatcher $dispatcher */ $dispatcher = $this->createMock(IEventDispatcher::class); + + $helper = new Config($config, $secureRandom, $groupManager, $userManager, $urlGenerator, $timeFactory, $dispatcher); + return $helper; + } + + public function testGetStunServers() { + $servers = [ + 'stun1.example.com:443', + 'stun2.example.com:129', + ]; + /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); $config @@ -59,19 +75,11 @@ class ConfigTest extends TestCase { ->with('has_internet_connection', true) ->willReturn(true); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertSame($helper->getStunServers(), $servers); } public function testGetDefaultStunServer() { - /** @var MockObject|ITimeFactory $timeFactory */ - $timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|ISecureRandom $secureRandom */ - $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ - $groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|IEventDispatcher $dispatcher */ - $dispatcher = $this->createMock(IEventDispatcher::class); /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); $config @@ -85,19 +93,11 @@ class ConfigTest extends TestCase { ->with('has_internet_connection', true) ->willReturn(true); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertSame(['stun.nextcloud.com:443'], $helper->getStunServers()); } public function testGetDefaultStunServerNoInternet() { - /** @var MockObject|ITimeFactory $timeFactory */ - $timeFactory = $this->createMock(ITimeFactory::class); - /** @var MockObject|ISecureRandom $secureRandom */ - $secureRandom = $this->createMock(ISecureRandom::class); - /** @var MockObject|IGroupManager $secureRandom */ - $groupManager = $this->createMock(IGroupManager::class); - /** @var MockObject|IEventDispatcher $dispatcher */ - $dispatcher = $this->createMock(IEventDispatcher::class); /** @var MockObject|IConfig $config */ $config = $this->createMock(IConfig::class); $config @@ -111,7 +111,7 @@ class ConfigTest extends TestCase { ->with('has_internet_connection', true) ->willReturn(false); - $helper = new Config($config, $secureRandom, $groupManager, $timeFactory, $dispatcher); + $helper = $this->createConfig($config); $this->assertSame([], $helper->getStunServers()); } @@ -151,8 +151,12 @@ class ConfigTest extends TestCase { ->method('getTime') ->willReturn(1479743025); - /** @var MockObject|IGroupManager $secureRandom */ + /** @var MockObject|IGroupManager $groupManager */ $groupManager = $this->createMock(IGroupManager::class); + /** @var MockObject|IUserManager $userManager */ + $userManager = $this->createMock(IUserMana |