Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F124727384
D5880.1778942027.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
35 KB
Referenced Files
None
Subscribers
None
D5880.1778942027.diff
View Options
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
Details
Attached
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.1778942027.diff (35 KB)
Attached To
Mode
D5880: PGP key server (HKP v1)
Attached
Detach File
Event Timeline