diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -81,6 +81,12 @@ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] +PGP_ENABLED= +PGP_BINARY= +PGP_AGENT= +PGP_GPGCONF= +PGP_HOMEDIR=app/keys + REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -5,7 +5,6 @@ use App\Sku; use App\User; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Kolab2FA\Storage\Base; /** @@ -315,16 +314,6 @@ */ public static function dbh() { - $dsn = \config('2fa.dsn'); - - if (empty($dsn)) { - \Log::warning("2-FACTOR database not configured"); - - return DB::connection(\config('database.default')); - } - - \Config::set('database.connections.2fa', ['url' => $dsn]); - - return DB::connection('2fa'); + return \App\Backends\Roundcube::dbh(); } } diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/PGP.php @@ -0,0 +1,199 @@ +deleteDirectory($homedir, !$del); + + // Remove all files from the Enigma database + // Note: This will cause existing files in the Roundcube filesystem + // to be removed, but only if the user used the Enigma functionality + Roundcube::enigmaCleanup($user->email); + } + + /** + * Generate a keypair. + * This will also initialize the user GPG homedir content. + * + * @param \App\User $user User object + * + * @throws \Exception + */ + public static function keypairCreate(User $user): void + { + // Make sure the homedir is empty + self::homedirCleanup($user); + + $keygen = new \Crypt_GPG_KeyGenerator(self::$config); + + $key = $keygen + // ->setPassphrase() + // ->setExpirationDate(0) + ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_DSA, 3072) + ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_ELGAMAL_ENC, 3072) + ->generateKey(null, $user->email); + + // Store the keypair in Roundcube Enigma storage + self::dbSave(true); + + // TODO: Register the public key in DNS + + // FIXME: Should we remove the files from the worker filesystem? + // They are still in database and Roundcube hosts' filesystem + } + + /** + * List (public and private) keys from a user keyring. + * + * @param \App\User $user User object + * + * @returns \Crypt_GPG_Key[] List of keys + * @throws \Exception + */ + public static function listKeys(User $user): array + { + $gpg = self::initGPG($user); + + return $gpg->getKeys(''); + } + + /** + * Debug logging callback + */ + public static function logDebug($msg): void + { + \Log::debug("[GPG] $msg"); + } + + /** + * Prepare Crypt_GPG configuration + */ + private static function getConfig(User $user, $nosync = false): array + { + if (!empty(self::$config) && self::$config['email'] == $user->email) { + return self::$config; + } + + $debug = \config('app.debug'); + $binary = \config('pgp.binary'); + $agent = \config('pgp.agent'); + $gpgconf = \config('pgp.gpgconf'); + $homedir = \config('pgp.homedir'); + + $options = [ + 'email' => $user->email, // this one is not a Crypt_GPG option + 'homedir' => self::setHomedir($homedir, $user), + 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null, + ]; + + if ($binary) { + $options['binary'] = $binary; + } + + if ($agent) { + $options['agent'] = $agent; + } + + if ($gpgconf) { + $options['gpgconf'] = $gpgconf; + } + + self::$config = $options; + + // Sync the homedir directory content with the Enigma storage + if (!$nosync) { + self::dbSync(); + } + + return $options; + } + + /** + * Initialize Crypt_GPG + */ + private static function initGPG(User $user) + { + return self::$gpg = new \Crypt_GPG(self::getConfig($user)); + } + + /** + * Prepare a homedir for the user (storage/keys/XX/user@email.addr) + */ + private static function setHomedir(string $homedir, User $user): string + { + if (!$homedir) { + throw new \Exception("Option 'pgp.homedir' not set"); + } + + if (!file_exists($homedir)) { + throw new \Exception("Keys directory doesn't exists: $homedir"); + } + + if (!is_writable($homedir)) { + throw new \Exception("Keys directory isn't writeable: $homedir"); + } + + // Create a subfolder using two first digits of the user ID + $homedir .= '/' . sprintf('%02d', substr((string) $user->id, 0, 2)); + + if (!file_exists($homedir)) { + mkdir($homedir, 0700); + } + + // Create the user homedir + $homedir .= '/' . $user->email; + + if (!file_exists($homedir)) { + mkdir($homedir, 0700); + } + + if (!file_exists($homedir)) { + throw new \Exception("Unable to create keys directory: $homedir"); + } + + if (!is_writable($homedir)) { + throw new \Exception("Unable to write to keys directory: $homedir"); + } + + return $homedir; + } + + /** + * Synchronize keys database of a user + */ + private static function dbSync(): void + { + Roundcube::enigmaSync(self::$config['email'], self::$config['homedir']); + } + + /** + * Save the keys database + */ + private static function dbSave($is_empty = false): void + { + Roundcube::enigmaSave(self::$config['email'], self::$config['homedir'], $is_empty); + } +} diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Roundcube.php @@ -0,0 +1,257 @@ +table(self::FILESTORE_TABLE) + ->where('user_id', self::userId($email)) + ->where('context', 'enigma') + ->delete(); + } + + /** + * List all files from the Enigma filestore. + * + * @param string $email User email address + * + * @return array List of Enigma filestore records + */ + public static function enigmaList(string $email): array + { + return self::dbh()->table(self::FILESTORE_TABLE) + ->where('user_id', self::userId($email)) + ->where('context', 'enigma') + ->orderBy('filename') + ->get() + ->all(); + } + + /** + * Synchronize Enigma filestore from/to specified directory + * + * @param string $email User email address + * @param string $homedir Directory location + */ + public static function enigmaSync(string $email, string $homedir): void + { + $db = self::dbh(); + $debug = \config('app.debug'); + $user_id = self::userId($email); + $files = []; + + $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') + ->where('user_id', $user_id) + ->where('context', 'enigma') + ->get(); + + foreach ($result as $record) { + $file = $homedir . '/' . $record->filename; + $mtime = @filemtime($file); + $files[] = $record->filename; + + if ($mtime < $record->mtime) { + $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime') + ->where('file_id', $record->file_id) + ->first(); + + $data = $record ? base64_decode($record->data) : false; + + if ($data === false) { + \Log::error("Failed to sync $file ({$record->file_id}). Decode error."); + continue; + } + + $tmpfile = $file . '.tmp'; + + if (file_put_contents($tmpfile, $data, LOCK_EX) === strlen($data)) { + rename($tmpfile, $file); + touch($file, $record->mtime); + + if ($debug) { + \Log::debug("[SYNC] Fetched file: $file"); + } + } else { + @unlink($tmpfile); + \Log::error("Failed to sync $file."); + } + } + } + + // Remove files not in database + foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) { + $file = $homedir . '/' . $file; + + if (unlink($file)) { + if ($debug) { + \Log::debug("[SYNC] Removed file: $file"); + } + } + } + + // No records found, do initial sync if already have the keyring + if (empty($file)) { + self::enigmaSave(true, $homedir); + } + } + + /** + * Save the keys database + * + * @param string $email User email address + * @param string $homedir Directory location + * @param bool $is_empty Set to Tre if it is a initial save + */ + public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void + { + $db = self::dbh(); + $debug = \config('app.debug'); + $user_id = self::userId($email); + $records = []; + + if (!$is_empty) { + $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') + ->where('user_id', $user_id) + ->where('context', 'enigma') + ->get() + ->each(function ($record) use ($records) { + $records[$record->filename] = $record; + }); + } + + foreach (self::enigmaFilesList($homedir) as $filename) { + $file = $homedir . '/' . $filename; + $mtime = @filemtime($file); + + $existing = !empty($records[$filename]) ? $records[$filename] : null; + unset($records[$filename]); + + if ($mtime && (empty($existing) || $mtime > $existing->mtime)) { + $data = file_get_contents($file); + $data = base64_encode($data); + $datasize = strlen($data); +/* + if (empty($maxsize)) { + $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; + } + + if ($datasize > $maxsize) { + \Log::error("Failed to save $file. Size exceeds max_allowed_packet."); + continue; + } +*/ + $result = $db->table(self::FILESTORE_TABLE)->updateOrInsert( + ['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename], + ['mtime' => $mtime, 'data' => $data] + ); + + if ($debug) { + \Log::debug("[SYNC] Pushed file: $file"); + } + } + } + + // Delete removed files from database + foreach (array_keys($records) as $filename) { + $file = $homedir . '/' . $filename; + $result = $db->table(self::FILESTORE_TABLE) + ->where('user_id', $user_id) + ->where('context', 'enigma') + ->where('filename', $filename) + ->delete(); + + if ($debug) { + \Log::debug("[SYNC] Removed file: $file"); + } + } + } + + /** + * Find the Roundcube user identifier for the specified user. + * + * @param string $email User email address + * @param bool $create Make sure the user record exists + * + * @returns ?int Roundcube user identifier + */ + public static function userId(string $email, bool $create = true): ?int + { + $db = self::dbh(); + + $user = $db->table(self::USERS_TABLE)->select('user_id') + ->where('username', \strtolower($email)) + ->first(); + + // Create a user record, without it we can't use the Roundcube storage + if (empty($user)) { + if (!$create) { + return null; + } + + $uri = \parse_url(\config('imap.uri')); + + return (int) $db->table(self::USERS_TABLE)->insertGetId( + [ + 'username' => $email, + 'mail_host' => $uri['host'], + 'created' => now()->toDateTimeString(), + ], + 'user_id' + ); + } + + return (int) $user->user_id; + } + + /** + * Returns list of Enigma user homedir files to backup/sync + */ + private static function enigmaFilesList(string $homedir) + { + $files = []; + + foreach (self::$enigma_files as $file) { + if (file_exists($homedir . '/' . $file)) { + $files[] = $file; + } + } + + foreach (glob($homedir . '/private-keys-v1.d/*.key') as $file) { + $files[] = ltrim(substr($file, strlen($homedir)), '/'); + } + + return $files; + } +} diff --git a/src/app/Jobs/PGP/KeyCreateJob.php b/src/app/Jobs/PGP/KeyCreateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/PGP/KeyCreateJob.php @@ -0,0 +1,46 @@ +isDeleted()`), or + * * the user is actually deleted (`$user->deleted_at`) + * * there was an error in keypair generation process + */ +class KeyCreateJob extends UserJob +{ + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + $user = $this->getUser(); + + if (!$user) { + return; + } + + // sanity checks + if ($user->isDeleted()) { + $this->fail(new \Exception("User {$this->userId} is marked as deleted.")); + return; + } + + if ($user->trashed()) { + $this->fail(new \Exception("User {$this->userId} is actually deleted.")); + return; + } + + \App\Backends\PGP::keypairCreate($user); + } +} diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -87,6 +87,10 @@ ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); + + if (\config('pgp.enable')) { + \App\Jobs\PGP\KeyCreateJob::dispatch($user->id); + } } /** diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -25,6 +25,7 @@ "laravel/tinker": "^2.4", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", + "pear/crypt_gpg": "dev-master", "silviolleite/laravelpwa": "^2.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", diff --git a/src/config/2fa.php b/src/config/2fa.php --- a/src/config/2fa.php +++ b/src/config/2fa.php @@ -9,6 +9,4 @@ 'issuer' => env('APP_NAME', 'Laravel'), ], - 'dsn' => env('MFA_DSN'), - ]; diff --git a/src/config/database.php b/src/config/database.php --- a/src/config/database.php +++ b/src/config/database.php @@ -95,6 +95,10 @@ 'prefix' => '', 'prefix_indexes' => true, ], + + 'roundcube' => [ + 'url' => env('DB_ROUNDCUBE_URL', env('MFA_DSN')), + ], ], /* diff --git a/src/config/pgp.php b/src/config/pgp.php new file mode 100644 --- /dev/null +++ b/src/config/pgp.php @@ -0,0 +1,20 @@ + env('PGP_ENABLE', false), + + // gpg binary location + 'binary' => env('PGP_BINARY'), + + // gpg-agent location + 'agent' => env('PGP_AGENT'), + + // gpgconf location + 'gpgconf' => env('PGP_GPGCONF'), + + // Temp directory for GPG operations + 'homedir' => env('PGP_HOMEDIR', storage_path('app/keys')), + +]; diff --git a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php @@ -0,0 +1,84 @@ +getTestUser('john@kolab.org'); + PGP::homedirCleanup($user); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $user = $this->getTestUser('john@kolab.org'); + PGP::homedirCleanup($user); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group pgp + */ + public function testHandle(): void + { + $user = $this->getTestUser('john@kolab.org'); + + $job = new \App\Jobs\PGP\KeyCreateJob($user->id); + $job->handle(); + + // Assert the Enigma storage has been initialized and contains the key + $files = Roundcube::enigmaList($user->email); + // TODO: More detailed asserts on the filestore content, but it's specific to GPG version + $this->assertTrue(count($files) > 0); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(1, $keys); + + $userIds = $keys[0]->getUserIds(); + $this->assertCount(1, $userIds); + $this->assertSame($user->email, $userIds[0]->getEmail()); + $this->assertSame('', $userIds[0]->getName()); + $this->assertSame('', $userIds[0]->getComment()); + $this->assertSame(true, $userIds[0]->isValid()); + $this->assertSame(false, $userIds[0]->isRevoked()); + + $key = $keys[0]->getPrimaryKey(); + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_DSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame(3072, $key->getLength()); + $this->assertSame(true, $key->hasPrivate()); + $this->assertSame(true, $key->canSign()); + $this->assertSame(false, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + $key = $keys[0]->getSubKeys()[1]; + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_ELGAMAL_ENC, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame(3072, $key->getLength()); + $this->assertSame(false, $key->canSign()); + $this->assertSame(true, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + // TODO: Assert the public key in DNS? + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -117,15 +117,14 @@ */ public function testCreateJobs(): void { - // Fake the queue, assert that no jobs were pushed... Queue::fake(); - Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, @@ -161,6 +160,35 @@ */ } + /** + * Verify user creation process invokes the PGP keys creation job (if configured) + */ + public function testCreatePGPJob(): void + { + Queue::fake(); + + \config(['pgp.enable' => true]); + + $user = User::create([ + 'email' => 'user-test@' . \config('app.domain') + ]); + + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); + + Queue::assertPushed( + \App\Jobs\PGP\KeyCreateJob::class, + function ($job) use ($user) { + $userEmail = TestCase::getObjectProperty($job, 'userEmail'); + $userId = TestCase::getObjectProperty($job, 'userId'); + + return $userEmail === $user->email + && $userId === $user->id; + } + ); + + \config(['pgp.enable' => false]); + } + /** * Tests for User::domains() */