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_LENGTH=
+
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,171 @@
+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
+ {
+ self::initConfig($user, true);
+
+ // 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_RSA, \config('pgp.length'))
+ ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length'))
+ ->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 initConfig(User $user, $nosync = false): void
+ {
+ if (!empty(self::$config) && self::$config['email'] == $user->email) {
+ return;
+ }
+
+ $debug = \config('app.debug');
+ $binary = \config('pgp.binary');
+ $agent = \config('pgp.agent');
+ $gpgconf = \config('pgp.gpgconf');
+
+ $dir = self::setHomedir($user);
+ $options = [
+ 'email' => $user->email, // this one is not a Crypt_GPG option
+ 'dir' => $dir, // this one is not a Crypt_GPG option
+ 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir,
+ '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();
+ }
+ }
+
+ /**
+ * Initialize Crypt_GPG
+ */
+ private static function initGPG(User $user)
+ {
+ self::initConfig($user);
+
+ return self::$gpg = new \Crypt_GPG(self::$config);
+ }
+
+ /**
+ * Prepare a homedir for the user
+ */
+ private static function setHomedir(User $user): string
+ {
+ // Create a subfolder using two first digits of the user ID
+ $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email;
+
+ Storage::disk('pgp')->makeDirectory($dir);
+
+ return $dir;
+ }
+
+ /**
+ * Synchronize keys database of a user
+ */
+ private static function dbSync(): void
+ {
+ Roundcube::enigmaSync(self::$config['email'], self::$config['dir']);
+ }
+
+ /**
+ * Save the keys database
+ */
+ private static function dbSave($is_empty = false): void
+ {
+ Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $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);
+ $root = \config('filesystems.disks.pgp.root');
+ $fs = Storage::disk('pgp');
+ $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 = $fs->exists($file) ? $fs->lastModified($file) : 0;
+ $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;
+ }
+
+ if ($fs->put($file, $data, true)) {
+ // Note: Laravel Filesystem API does not provide touch method
+ touch("$root/$file", $record->mtime);
+
+ if ($debug) {
+ \Log::debug("[SYNC] Fetched file: $file");
+ }
+ }
+ }
+ }
+
+ // Remove files not in database
+ foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) {
+ $file = $homedir . '/' . $file;
+
+ if ($fs->delete($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);
+ $fs = Storage::disk('pgp');
+ $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 = $fs->exists($file) ? $fs->lastModified($file) : 0;
+
+ $existing = !empty($records[$filename]) ? $records[$filename] : null;
+ unset($records[$filename]);
+
+ if ($mtime && (empty($existing) || $mtime > $existing->mtime)) {
+ $data = base64_encode($fs->get($file));
+/*
+ if (empty($maxsize)) {
+ $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000;
+ }
+
+ if (strlen($data) > $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 = [];
+ $fs = Storage::disk('pgp');
+
+ foreach (self::$enigma_files as $file) {
+ if ($fs->exists($homedir . '/' . $file)) {
+ $files[] = $file;
+ }
+ }
+
+ foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) {
+ if (preg_match('/\.key$/', $file)) {
+ $files[] = 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/filesystems.php b/src/config/filesystems.php
--- a/src/config/filesystems.php
+++ b/src/config/filesystems.php
@@ -48,6 +48,11 @@
'root' => storage_path('app'),
],
+ 'pgp' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/keys'),
+ ],
+
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
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'),
+
+ // Default size of the new RSA key
+ 'length' => (int) env('PGP_LENGTH', 3072),
+
+];
diff --git a/src/phpunit.xml b/src/phpunit.xml
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -40,5 +40,6 @@
+
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) > 1);
+
+ // 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_RSA, $key->getAlgorithm());
+ $this->assertSame(0, $key->getExpirationDate());
+ $this->assertSame((int) \config('pgp.length'), $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_RSA, $key->getAlgorithm());
+ $this->assertSame(0, $key->getExpirationDate());
+ $this->assertSame((int) \config('pgp.length'), $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()
*/