Page MenuHomePhorge

D5880.1778942039.diff
No OneTemporary

Authored By
Unknown
Size
35 KB
Referenced Files
None
Subscribers
None

D5880.1778942039.diff

diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php
--- a/src/app/Backends/PGP.php
+++ b/src/app/Backends/PGP.php
@@ -2,11 +2,7 @@
namespace App\Backends;
-use App\PowerDNS\Domain;
-use App\PowerDNS\Record;
use App\User;
-use App\Utils;
-use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class PGP
@@ -74,11 +70,8 @@
// Store the keypair in Roundcube Enigma storage
self::dbSave(true);
- // Get the ASCII armored data of the public key
- $armor = self::$gpg->exportPublicKey((string) $key, true);
-
- // Register the public key in DNS
- self::keyRegister($email, $armor);
+ // Register the public key in the system
+ self::keyRegister($user, $email, $key);
// FIXME: Should we remove the files from the worker filesystem?
// They are still in database and Roundcube hosts' filesystem
@@ -95,13 +88,14 @@
public static function keyDelete(User $user, string $email): void
{
// Start with the DNS, it's more important
- self::keyUnregister($email);
+ self::keyUnregister($user, $email);
- // Remove the whole Enigma keyring (if it's a delete user account)
+ // Remove the whole Enigma keyring if deleting a user
if ($user->email === $email) {
self::homedirCleanup($user);
- $user->aliases()->pluck('alias')->each(static fn ($alias) => self::keyUnregister($alias));
+ $user->aliases()->pluck('alias')->each(static fn ($alias) => self::keyUnregister($user, $alias));
}
+
// TODO: remove only the alias key from Enigma keyring
}
@@ -130,47 +124,51 @@
}
/**
- * Register the key in the WOAT DNS system
- *
- * @param string $email Email address
- * @param string $key The ASCII-armored key content
+ * Check if a specific PGP service is enabled
*/
- private static function keyRegister(string $email, string $key): void
+ public static function isEnabled(string $service): bool
{
- [$local, $domain] = Utils::normalizeAddress($email, true);
-
- DB::beginTransaction();
-
- $domain = Domain::firstOrCreate([
- 'name' => '_woat.' . $domain,
- ]);
+ return in_array(strtolower($service), \config('pgp.services'));
+ }
- Record::create([
- 'domain_id' => $domain->id,
- 'name' => sha1($local) . '.' . $domain->name,
- 'type' => 'TXT',
- 'content' => 'v=woat1,public_key=' . $key,
- ]);
+ /**
+ * Get the ASCII armored data of a public key
+ *
+ * @param User $user User object
+ * @param \Crypt_GPG_Key $key Key
+ */
+ public static function exportPublicKey(User $user, \Crypt_GPG_Key $key): string
+ {
+ self::initGPG($user, true);
- DB::commit();
+ return self::$gpg->exportPublicKey((string) $key, true);
}
/**
- * Remove the key from the WOAT DNS system
- *
- * @param string $email Email address
+ * Register a new key in the system (WOAT, HKP)
*/
- private static function keyUnregister(string $email): void
+ private static function keyRegister(User $user, string $email, \Crypt_GPG_Key $key): void
{
- [$local, $domain] = Utils::normalizeAddress($email, true);
+ if (self::isEnabled('woat')) {
+ PGP\WOAT::addKey($user, $email, $key);
+ }
- $domain = Domain::where('name', '_woat.' . $domain)->first();
+ if (self::isEnabled('hkp')) {
+ PGP\Keyserver::addKey($user, $key);
+ }
+ }
- if ($domain) {
- $fqdn = sha1($local) . '.' . $domain->name;
+ /**
+ * Remove the key from the system (WOAT, HKP)
+ */
+ private static function keyUnregister(User $user, string $email): void
+ {
+ if (self::isEnabled('woat')) {
+ PGP\WOAT::deleteKey($user, $email);
+ }
- // For now we support only one WOAT key record
- $domain->records()->where('name', $fqdn)->delete();
+ if (self::isEnabled('hkp')) {
+ PGP\Keyserver::deleteKey($user, $email);
}
}
diff --git a/src/app/Backends/PGP/Key.php b/src/app/Backends/PGP/Key.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/PGP/Key.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Backends\PGP;
+
+use App\User;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Collection;
+
+/**
+ * Definition of PGP (public) key
+ *
+ * @property int $algorithm Primary key algorithm
+ * @property string $content Key ascii-armored content
+ * @property ?\DateTime $expires_at Key expiration date
+ * @property string $id Key identifier
+ * @property bool $is_revoked Key revocation state
+ * @property int $length Primary key algorithm bit length
+ * @property int $user_id Key owner
+ */
+class Key extends Model
+{
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ 'expires_at' => 'datetime:Y-m-d H:i:s',
+ 'is_revoked' => 'boolean',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'algorithm',
+ 'expires_at',
+ 'id',
+ 'content',
+ 'is_revoked',
+ 'length',
+ 'user_id',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'pgp_keys';
+
+ /** @var bool Enable primary key autoincrement (required here for Pivots) */
+ public $incrementing = false;
+
+ /**
+ * Get multipe keys by an email address
+ */
+ public static function getByEmail($email, $limit = null): Collection
+ {
+ // Per HKP RFC: sort in order of decreasing confidence in identity, and then by creation date (most recent first)
+ $ids = self::select('pgp_keys.id')->distinct()
+ ->join('pgp_identities', 'pgp_identities.key_id', '=', 'pgp_keys.id')
+ ->where('email', $email)
+ ->orderByRaw('(case'
+ . ' when pgp_keys.is_revoked then 3'
+ . ' when pgp_identities.is_revoked then 2'
+ . ' when pgp_keys.expires_at < now() then 1'
+ . ' else 0'
+ . ' end)')
+ ->orderBy('pgp_keys.created_at', 'desc');
+
+ if ($limit) {
+ $ids->limit($limit);
+ }
+
+ $ids = $ids->pluck('id')->all();
+
+ if (empty($ids)) {
+ return collect([]);
+ }
+
+ // Get the keys in the same order
+ return self::whereIn('id', $ids)->get()
+ ->sortBy(function ($key) use ($ids) {
+ return array_search($key->id, $ids);
+ });
+ }
+
+ /**
+ * Extract key ID (integer) from a hex fingerprint
+ */
+ public static function fingerprintId(string $fingerprint): string
+ {
+ // Note: We can't be using integer type as PHP does not support unsigned int.
+ // So, we operate on strings
+ return substr($fingerprint, -16);
+ }
+
+ /**
+ * Find a key by an email address
+ */
+ public static function findByEmail(string $email): ?self
+ {
+ $keys = KeyIdentity::select('key_id')->distinct()
+ ->where('email', $email)
+ ->limit(2)
+ ->get();
+
+ // TODO: We may consider returning a key if there's more than one, but
+ // all but one are revoked (or expired?)
+
+ return $keys->count() === 1 ? self::find($keys[0]->key_id) : null;
+ }
+
+ /**
+ * Find a key by a key identifier (ID or fingerprint)
+ */
+ public static function findById(string $id): ?self
+ {
+ $id = self::fingerprintId($id);
+
+ // Lookup the main key
+ if ($key = self::find($id)) {
+ return $key;
+ }
+
+ // Lookup via a subkey
+ $subkey = KeySubkey::find($id);
+
+ return $subkey ? self::find($subkey->key_id) : null;
+ }
+
+ /**
+ * The key identities (users)
+ *
+ * @return HasMany<KeyIdentity, $this>
+ */
+ public function identities()
+ {
+ return $this->hasMany(KeyIdentity::class, 'key_id');
+ }
+
+ /**
+ * The key subkeys
+ *
+ * @return HasMany<KeySubkey, $this>
+ */
+ public function subkeys()
+ {
+ return $this->hasMany(KeySubkey::class, 'key_id');
+ }
+
+ /**
+ * The key owner
+ *
+ * @return BelongsTo<User, $this>
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+}
diff --git a/src/app/Backends/PGP/KeyIdentity.php b/src/app/Backends/PGP/KeyIdentity.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/PGP/KeyIdentity.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Backends\PGP;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * Definition of PGP key user identity (for user ID lookups)
+ *
+ * @property string $email Email address
+ * @property int $id User identifier
+ * @property string $identity Full user identity line (including email address)
+ * @property bool $is_revoked User revocation state
+ * @property string $key_id Key identifier
+ */
+class KeyIdentity extends Model
+{
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ 'is_revoked' => 'boolean',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'email',
+ 'identity',
+ 'is_revoked',
+ 'key_id',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'pgp_identities';
+
+ /**
+ * The public key
+ *
+ * @return BelongsTo<Key, $this>
+ */
+ public function key()
+ {
+ return $this->belongsTo(Key::class, 'key_id');
+ }
+}
diff --git a/src/app/Backends/PGP/KeySubkey.php b/src/app/Backends/PGP/KeySubkey.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/PGP/KeySubkey.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Backends\PGP;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * Definition of PGP subkey (for subkey ID lookups)
+ *
+ * @property string $id Subkey identifier
+ * @property string $key_id Key identifier
+ */
+class KeySubkey extends Model
+{
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'id',
+ 'key_id',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'pgp_subkeys';
+
+ /** @var bool Enable primary key autoincrement (required here for Pivots) */
+ public $incrementing = false;
+
+ /**
+ * The public key
+ *
+ * @return BelongsTo<Key, $this>
+ */
+ public function key()
+ {
+ return $this->belongsTo(Key::class, 'key_id');
+ }
+}
diff --git a/src/app/Backends/PGP/Keyserver.php b/src/app/Backends/PGP/Keyserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/PGP/Keyserver.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Backends\PGP;
+
+use App\Backends\PGP;
+use App\User;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * PGP key server backend
+ */
+class Keyserver
+{
+ /**
+ * Add a new key to the server
+ */
+ public static function addKey(User $user, \Crypt_GPG_Key $key): void
+ {
+ // Get the ASCII armored data of the public key
+ $armor = PGP::exportPublicKey($user, $key);
+
+ $pkey = $key->getPrimaryKey();
+ $key_id = Key::fingerprintId($pkey->getFingerprint());
+
+ DB::beginTransaction();
+
+ $record = Key::create([
+ 'id' => $key_id,
+ 'user_id' => $user->id,
+ 'content' => $armor,
+ 'is_revoked' => $pkey->isRevoked(),
+ 'expires_at' => $pkey->getExpirationDateTime(),
+ 'created_at' => $pkey->getCreationDateTime(),
+ 'algorithm' => $pkey->getAlgorithm(),
+ 'length' => $pkey->getLength(),
+ ]);
+
+ $emails = [];
+ foreach ($key->getUserIds() as $userid) {
+ if ($userid->isValid()) {
+ if (($email = $userid->getEmail()) && !in_array($email, $emails)) {
+ $record->identities()->create([
+ 'email' => $email,
+ 'identity' => (string) $userid,
+ 'is_revoked' => $userid->isRevoked(),
+ ]);
+ $emails[] = $email;
+ }
+ }
+ }
+
+ foreach ($key->getSubKeys() as $subkey) {
+ if (!$subkey->isRevoked()) {
+ $subkey_id = Key::fingerprintId($subkey->getFingerprint());
+ if ($subkey_id != $key_id) {
+ $record->subkeys()->create(['id' => $subkey_id]);
+ }
+ }
+ }
+
+ DB::commit();
+ }
+
+ /**
+ * Remove a key from the server
+ */
+ public static function deleteKey(User $user, string $email): void
+ {
+ // TODO
+ }
+
+ /**
+ * Remove a key from the server
+ */
+ public static function updateKey(\Crypt_GPG_Key $key): void
+ {
+ // TODO
+ }
+}
diff --git a/src/app/Backends/PGP/WOAT.php b/src/app/Backends/PGP/WOAT.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/PGP/WOAT.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Backends\PGP;
+
+use App\Backends\PGP;
+use App\PowerDNS\Domain;
+use App\User;
+use App\Utils;
+use Illuminate\Support\Facades\DB;
+
+class WOAT
+{
+ /**
+ * Register a new key in the system (WOAT, HKP)
+ */
+ public static function addKey(User $user, string $email, \Crypt_GPG_Key $key): void
+ {
+ // Get the ASCII armored data of the public key
+ $armor = PGP::exportPublicKey($user, $key);
+
+ [$local, $domain] = Utils::normalizeAddress($email, true);
+
+ DB::beginTransaction();
+
+ $domain = Domain::firstOrCreate([
+ 'name' => '_woat.' . $domain,
+ ]);
+
+ $domain->records()->create([
+ 'name' => sha1($local) . '.' . $domain->name,
+ 'type' => 'TXT',
+ 'content' => 'v=woat1,public_key=' . $armor,
+ ]);
+
+ DB::commit();
+ }
+
+ /**
+ * Remove the key from the system (WOAT, HKP)
+ */
+ public static function deleteKey(User $user, string $email): void
+ {
+ [$local, $domain] = Utils::normalizeAddress($email, true);
+
+ $domain = Domain::where('name', '_woat.' . $domain)->first();
+
+ if ($domain) {
+ $fqdn = sha1($local) . '.' . $domain->name;
+
+ // For now we support only one WOAT key record
+ $domain->records()->where('name', $fqdn)->delete();
+ }
+ }
+}
diff --git a/src/app/Http/Controllers/KeyserverController.php b/src/app/Http/Controllers/KeyserverController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/KeyserverController.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Backends\PGP\Key;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+
+class KeyserverController extends Controller
+{
+ /**
+ * HKP lookup
+ *
+ * Partial implementation of HKP v1 lookups
+ * https://www.ietf.org/archive/id/draft-gallagher-openpgp-hkp-10.html#legacy-lookups
+ */
+ public function lookupHKPv1(Request $request): Response
+ {
+ // Note: We use the same limitations as Hagrid, see https://keys.openpgp.org/about/api
+ // - Only exact matches by email address, fingerprint or long key id are returned.
+ // - All parameters and options other than op and search are ignored.
+ // - Output is always machine readable (i.e. options=mr is always assumed).
+ // - We return public key as is (no filtering of extra packets).
+
+ if (!in_array($request->op, ['get', 'index', 'vindex'])) {
+ return response("Not supported 'op' parameter value", 501)
+ ->header('Content-Type', 'text/plain')
+ ->header('Access-Control-Allow-Origin', '*');
+ }
+
+ if (!is_string($request->search) || !strlen($request->search)) {
+ return response("Empty or invalid 'search' parameter value", 422)
+ ->header('Content-Type', 'text/plain')
+ ->header('Access-Control-Allow-Origin', '*');
+ }
+
+ $keys = [];
+ $key = null;
+
+ // Find the key
+ if (str_contains($request->search, '@')) {
+ // by email address
+ if ($request->op == 'get') {
+ $key = Key::findByEmail($request->search);
+ } else {
+ $keys = Key::getByEmail($request->search, limit: 10);
+ }
+ } else {
+ // by a key ID or fingerprint:
+ // - A hexadecimal representation of a long KeyID (e.g., 069C0C348DD82C19, optionally prefixed by 0x).
+ // This may be a KeyID of either a primary key or a subkey.
+ // - A hexadecimal representation of a Fingerprint (e.g., 8E8C33FA4626337976D97978069C0C348DD82C19, optionally prefixed by 0x).
+ // This may be a Fingerprint of either a primary key or a subkey.
+
+ $search = str_replace('0x', '', $request->search);
+ $key = Key::findById($search);
+
+ if ($request->op != 'get') {
+ $keys = $key ? [$key] : [];
+ }
+ }
+
+ if (empty($key) && !count($keys)) {
+ return response('Not found', 404)
+ ->header('Content-Type', 'text/plain')
+ ->header('Access-Control-Allow-Origin', '*');
+ }
+
+ if ($request->op == 'get') {
+ // Key output is ascii-armored
+ return response($key->content, 200)
+ ->header('Access-Control-Allow-Origin', '*')
+ ->header('Last-Modified', $key->updated_at->format(\DateTime::RFC7231))
+ ->header('Content-Type', 'application/pgp-keys');
+ }
+
+ $output = ['info:1:' . count($keys)];
+
+ foreach ($keys as $key) {
+ $output[] = implode(':', [
+ 'pub',
+ $key->id,
+ $key->algorithm, // algorithm
+ $key->length, // bit length
+ $key->created_at->format('U'),
+ $key->expires_at ? $key->expires_at->format('U') : '',
+ self::keyFlags($key), // flags (r - revoked, e - expired, d - disabled)
+ ]);
+
+ $key->identities()->each(function ($ident) use (&$output) {
+ $output[] = implode(':', [
+ 'uid',
+ rawurlencode($ident->identity), // identity full content (RFC3986 percent encoded)
+ $ident->created_at->format('U'),
+ '',
+ $ident->is_revoked ? 'r' : '', // flags (r - revoked, e - expired, d - disabled)
+ ]);
+ });
+ }
+
+ return response(implode("\n", $output), 200)
+ ->header('Access-Control-Allow-Origin', '*')
+ ->header('Content-Type', 'text/plain');
+ }
+
+ /**
+ * Get HKPv1 flags for a key
+ */
+ protected static function keyFlags($key): string
+ {
+ // flags: r - revoked, e - expired, d - disabled
+ return ($key->is_revoked ? 'r' : '')
+ . ($key->expires_at && $key->expires_at < now() ? 'e' : '');
+ }
+}
diff --git a/src/config/pgp.php b/src/config/pgp.php
--- a/src/config/pgp.php
+++ b/src/config/pgp.php
@@ -15,4 +15,9 @@
// Default size of the new RSA key
'length' => (int) env('PGP_LENGTH', 3072),
+
+ // Enables services:
+ // - WOAT: Keys stored in built-in DNS database
+ // - HKP: Built-in HTTP key server(s)
+ 'services' => \explode(',', \strtolower(env('PGP_SERVICES', ''))),
];
diff --git a/src/database/migrations/2026_05_01_100000_create_pgp_keys_tables.php b/src/database/migrations/2026_05_01_100000_create_pgp_keys_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2026_05_01_100000_create_pgp_keys_tables.php
@@ -0,0 +1,79 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'pgp_keys',
+ static function (Blueprint $table) {
+ // key ID is the lowest 64 bits of a fingerprint (last 16 characters in a hex fingerprint)
+ // Note: Can't use integer because in PHP int is signed
+ $table->string('id', 16)->primary();
+ // v4 key fingerprint is 40-char, but v6 is 64-char
+ // $table->string('fingerprint', 64)->index();
+ $table->bigInteger('user_id')->index();
+ $table->mediumText('content');
+ $table->unsignedSmallInteger('algorithm')->nullable();
+ $table->unsignedSmallInteger('length')->nullable();
+ $table->boolean('is_revoked')->default(false);
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+
+ Schema::create(
+ 'pgp_subkeys',
+ static function (Blueprint $table) {
+ // subkey ID is the lowest 64 bits of a fingerprint (last 16 characters in a hex fingerprint)
+ // Note: Can't use integer because in PHP int is signed
+ $table->string('id', 16)->primary();
+ // v4 key fingerprint is 40-char, but v6 is 64-char
+ // $table->string('fingerprint', 64)->index();
+ $table->string('key_id', 16);
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->unique(['key_id', 'id']);
+
+ $table->foreign('key_id')->references('id')->on('pgp_keys')->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+
+ Schema::create(
+ 'pgp_identities',
+ static function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('key_id', 16);
+ $table->string('email')->index();
+ $table->string('identity', 512);
+ $table->boolean('is_revoked')->default(false);
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->unique(['key_id', 'email']);
+
+ $table->foreign('key_id')->references('id')->on('pgp_keys')->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('pgp_identities');
+ Schema::dropIfExists('pgp_subkeys');
+ Schema::dropIfExists('pgp_keys');
+ }
+};
diff --git a/src/phpunit.xml b/src/phpunit.xml
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -43,6 +43,7 @@
<server name="SESSION_DRIVER" value="array"/>
<server name="SWOOLE_HTTP_ACCESS_LOG" value="false"/>
<server name="PGP_LENGTH" value="1024"/>
+ <server name="PGP_SERVICES" value="hkp,woat"/>
<server name="DAV_WITH_DEFAULT_FOLDERS" value="false"/>
<server name="IMAP_CREATE_EXCEPTION_DISABLE" value="false"/>
</php>
diff --git a/src/routes/web.php b/src/routes/web.php
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -71,3 +71,8 @@
}
Controllers\DiscoveryController::registerRoutes();
+
+if (in_array('hkp', \config('pgp.services'))) {
+ Route::get('/pks/lookup', [Controllers\KeyserverController::class, 'lookupHKPv1']);
+ // Route::get('/pks/v2/certs/{mode}/{search}', [Controllers\KeyserverController::class, 'lookupHKPv2']);
+}
diff --git a/src/tests/Feature/Backends/PGPTest.php b/src/tests/Feature/Backends/PGPTest.php
--- a/src/tests/Feature/Backends/PGPTest.php
+++ b/src/tests/Feature/Backends/PGPTest.php
@@ -3,6 +3,7 @@
namespace Tests\Feature\Backends;
use App\Backends\PGP;
+use App\Backends\PGP\Key;
use App\Backends\Roundcube;
use App\PowerDNS\Domain;
use Illuminate\Support\Facades\Queue;
@@ -18,6 +19,7 @@
$user->aliases()->where('alias', 'test-alias@kolab.org')->delete();
PGP::homedirCleanup($user);
Domain::where('name', '_woat.kolab.org')->delete();
+ Key::query()->delete();
}
protected function tearDown(): void
@@ -26,18 +28,20 @@
$user->aliases()->where('alias', 'test-alias@kolab.org')->delete();
PGP::homedirCleanup($user);
Domain::where('name', '_woat.kolab.org')->delete();
+ Key::query()->delete();
parent::tearDown();
}
/**
- * Test key pair, listing and deletion creation.
+ * Test key pair creation, listing and deletion.
*
* @group pgp
* @group roundcube
*/
public function testKeyCreateListDelete(): void
{
+ \config(['pgp.services' => ['woat', 'hkp']]);
Queue::fake();
$user = $this->getTestUser('john@kolab.org');
@@ -56,6 +60,7 @@
$keys = PGP::listKeys($user);
$this->assertCount(1, $keys);
+ $keyId = Key::fingerprintId((string) $keys[0]);
$userIds = $keys[0]->getUserIds();
$this->assertCount(1, $userIds);
$this->assertSame($user->email, $userIds[0]->getEmail());
@@ -81,7 +86,7 @@
$this->assertTrue($key->canEncrypt());
$this->assertFalse($key->isRevoked());
- // Assert the public key in DNS
+ // WOAT: Assert the public key in DNS
$dns_domain = Domain::where('name', '_woat.kolab.org')->first();
$this->assertNotNull($dns_domain);
$dns_record = $dns_domain->records()->where('type', 'TXT')->first();
@@ -97,6 +102,24 @@
$dns_record->content
);
+ // HKP: Keyserver
+ $key = Key::find($keyId);
+ $this->assertInstanceOf(Key::class, $key);
+ $this->assertMatchesRegularExpression(
+ '/-----BEGIN PGP PUBLIC KEY BLOCK-----[a-zA-Z0-9\n\/+=]+-----END PGP PUBLIC KEY BLOCK-----$/',
+ $key->content
+ );
+ $this->assertFalse($key->is_revoked);
+ $this->assertNull($key->expires_at);
+ $this->assertSame(\config('pgp.length'), $key->length);
+ $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->algorithm);
+
+ $this->assertCount(1, $key_users = $key->identities()->get());
+ $this->assertSame($user->email, $key_users[0]->email);
+ $subkeys = $keys[0]->getSubKeys();
+ $this->assertCount(1, $key_subkeys = $key->subkeys()->get());
+ $this->assertSame(Key::fingerprintId($keys[0]->getSubKeys()[1]->getFingerprint()), $key_subkeys[0]->id);
+
// Test an alias
$alias = $user->aliases()->create(['alias' => 'test-alias@kolab.org']);
PGP::keypairCreate($user, $alias->alias);
@@ -130,12 +153,19 @@
$this->assertTrue($key->canEncrypt());
$this->assertFalse($key->isRevoked());
+ // WOAT
$this->assertSame(2, $dns_domain->records()->where('type', 'TXT')->count());
+ // TODO: HKP
+
// Delete the key
PGP::keyDelete($user, $user->email);
- $this->assertSame(0, $dns_domain->records()->where('type', 'TXT')->count());
$this->assertCount(0, PGP::listKeys($user));
+
+ // WOAT
+ $this->assertSame(0, $dns_domain->records()->where('type', 'TXT')->count());
+
+ // TODO: HKP
}
}
diff --git a/src/tests/Feature/Controller/KeyserverTest.php b/src/tests/Feature/Controller/KeyserverTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/KeyserverTest.php
@@ -0,0 +1,179 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Backends\PGP\Key;
+use Tests\TestCase;
+
+/**
+ * @group pgp
+ */
+class KeyserverTest extends TestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('keyserver@kolab.org');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->deleteTestUser('keyserver@kolab.org');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test HKPv1 lookups
+ */
+ public function testLookupHKPv1(): void
+ {
+ $user = $this->getTestUser('keyserver@kolab.org');
+
+ $this->get('pks/lookup')
+ ->assertStatus(501)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $this->get('pks/lookup?op=stats')
+ ->assertStatus(501)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $this->get('pks/lookup?op=get')
+ ->assertStatus(422)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $this->get('pks/lookup?op=get&search=')
+ ->assertStatus(422)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $this->get('pks/lookup?op=get&search=0x1234')
+ ->assertStatus(404)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $this->get("pks/lookup?op=get&search={$user->email}")
+ ->assertStatus(404)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ // Test success cases ========================
+ $key = Key::create([
+ 'user_id' => $user->id,
+ 'id' => '12345678abcdef12',
+ 'content' => "-----BEGIN PGP PUBLIC KEY BLOCK-----\n123456789\n-----END PGP PUBLIC KEY BLOCK-----",
+ 'algorithm' => \Crypt_GPG_SubKey::ALGORITHM_RSA,
+ 'length' => 2048,
+ 'is_revoked' => false,
+ 'expires_at' => null,
+ ]);
+ $subkey1 = $key->subkeys()->create(['id' => 'AABBCCDDAABBCCDD']);
+ $subkey2 = $key->subkeys()->create(['id' => '1122334455667788']);
+ $key->identities()->create([
+ 'email' => 'keyserver@kolab.org',
+ 'identity' => '"test user" <keyserver@kolab.org>',
+ 'is_revoked' => false,
+ ]);
+ $key->identities()->create([
+ 'email' => 'alias@kolab.org',
+ 'identity' => '<alias@kolab.org>',
+ 'is_revoked' => false,
+ ]);
+
+ // Search by email address
+ $this->get("pks/lookup?op=get&search={$user->email}")
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Last-Modified', $key->created_at->format(\DateTime::RFC7231))
+ ->assertHeader('Content-Type', 'application/pgp-keys')
+ ->assertContent($key->content);
+
+ // Search by the primary key ID
+ $this->get("pks/lookup?op=get&search={$key->id}")
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Last-Modified', $key->created_at->format(\DateTime::RFC7231))
+ ->assertHeader('Content-Type', 'application/pgp-keys')
+ ->assertContent($key->content);
+
+ // Search by the primary key ID (uppercase)
+ $this->get("pks/lookup?op=get&search=" . strtoupper($key->id))
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Last-Modified', $key->created_at->format(\DateTime::RFC7231))
+ ->assertHeader('Content-Type', 'application/pgp-keys')
+ ->assertContent($key->content);
+
+ // Search by the primary key fingerprint
+ $this->get("pks/lookup?op=get&search=AABBCCDDAABBCCDD{$key->id}")
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Last-Modified', $key->created_at->format(\DateTime::RFC7231))
+ ->assertHeader('Content-Type', 'application/pgp-keys')
+ ->assertContent($key->content);
+
+ // Search by the subkey fingerprint
+ $this->get("pks/lookup?op=get&search=AABBCCDDAABBCCDD{$subkey2->id}")
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Last-Modified', $key->created_at->format(\DateTime::RFC7231))
+ ->assertHeader('Content-Type', 'application/pgp-keys')
+ ->assertContent($key->content);
+
+ // Test op=index requests ===========================
+ $this->get('pks/lookup?op=index&search=0x1234')
+ ->assertStatus(404)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $this->get('pks/lookup?op=index&search=test@unknown.com')
+ ->assertStatus(404)
+ ->assertHeader('Access-Control-Allow-Origin', '*');
+
+ $key2 = Key::create([
+ 'user_id' => $user->id,
+ 'id' => 'abcabcabcabcabca',
+ 'content' => "-----BEGIN PGP PUBLIC KEY BLOCK-----\naaaaaaaa\n-----END PGP PUBLIC KEY BLOCK-----",
+ 'algorithm' => 2,
+ 'length' => 1024,
+ 'is_revoked' => true,
+ 'expires_at' => ($dt = now()->subDays(30)),
+ ]);
+ $key2->identities()->create([
+ 'email' => 'keyserver@kolab.org',
+ 'identity' => 'test2 <keyserver@kolab.org>',
+ 'is_revoked' => true,
+ ]);
+
+ // Search by the subkey fingerprint
+ $expected = "/^info:1:1\n"
+ . "pub:{$key->id}:1:2048:[0-9]+::\n"
+ . "uid:%22test%20user%22%20%3Ckeyserver%40kolab.org%3E:[0-9]+::\n"
+ . "uid:%3Calias%40kolab.org%3E:[0-9]+::\$/";
+
+ $content = $this->get("pks/lookup?op=index&search=AABBCCDDAABBCCDD{$subkey2->id}")
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Content-Type', 'text/plain; charset=utf-8')
+ ->getContent();
+
+ $this->assertMatchesRegularExpression($expected, $content);
+
+ // Search by email matching two keys
+ $expected = "/^info:1:2\n"
+ . "pub:{$key->id}:1:2048:[0-9]+::\n"
+ . "uid:%22test%20user%22%20%3Ckeyserver%40kolab.org%3E:[0-9]+::\n"
+ . "uid:%3Calias%40kolab.org%3E:[0-9]+::\n"
+ . "pub:{$key2->id}:2:1024:[0-9]+:" . $dt->format('U') . ":re\n"
+ . "uid:test2%20%3Ckeyserver%40kolab.org%3E:[0-9]+::r\$/";
+
+ $content = $this->get("pks/lookup?op=index&search=keyserver@kolab.org")
+ ->assertStatus(200)
+ ->assertHeader('Access-Control-Allow-Origin', '*')
+ ->assertHeader('Content-Type', 'text/plain; charset=utf-8')
+ ->getContent();
+
+ $this->assertMatchesRegularExpression($expected, $content);
+
+ // TODO: op=get should probably return the not revoked/expired one
+ $this->get("pks/lookup?op=get&search={$user->email}")
+ ->assertStatus(404);
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Sat, May 16, 2:33 PM (5 d, 3 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18924563
Default Alt Text
D5880.1778942039.diff (35 KB)

Event Timeline