diff --git a/.github/AGENTS.adoc b/.github/AGENTS.adoc index 9afdea9..1f5bad8 100644 --- a/.github/AGENTS.adoc +++ b/.github/AGENTS.adoc @@ -160,6 +160,17 @@ Every mutating command is wrapped in a BTX lifecycle: * Use `@Nullable` sparingly and only at boundaries that genuinely allow absence. * Prefer `Optional` instead of `@Nullable` for return types. * Never use `Optional` in fields, and avoid `Optional` parameters unless there is no cleaner API shape. ++ +[NOTE] +==== +*Exception — immutable DTO record components:* `Optional` is acceptable as a record _component_ when all of the +following hold: (a) the record is an immutable value-type DTO that is never persisted or serialized to an external +format, (b) the optionality is an intrinsic part of the domain concept (e.g. `expirationTime` that may genuinely be +absent), and (c) there is no natural sentinel or null-object alternative. +`KeyIndexResult` and `UidIndexEntry` satisfy these criteria — they are transient application-layer DTOs consumed and +discarded within a single request, never mapped by JPA or written to JSON/XML. +JPA entity fields and any class with `@XmlRootElement`, `@JsonSerialize`, or similar must still not use `Optional`. +==== * Avoid mixing docs, refactors, and behavior changes unless the task needs it. * After each Java or `pom.xml` change, run `./mvnw spotless:apply`. diff --git a/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyIndexResult.java b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyIndexResult.java new file mode 100644 index 0000000..8e751ec --- /dev/null +++ b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyIndexResult.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023-2024 The java-keyserver project team. + * + * SPDX-License-Identifier: EUPL-1.2 OR Apache-2.0 + */ +package io.github.bmarwell.keyserver.application.api; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +/// Full index entry for one key, used in `op=index` responses. +/// +/// Contains the key metadata fields needed for the HKP machine-readable +/// format (`pub:` line) plus all verified UIDs (`uid:` lines). +/// Only verified UIDs appear in {@code verifiedUids} — unverified UIDs +/// are never exposed. +/// +/// The {@code disabled} flag is a keyserver-administrative flag (HKP `d`). +/// It is independent of the OpenPGP {@code revoked} flag: an operator may +/// disable a key on this server without the key owner having revoked it. +public record KeyIndexResult( + String fingerprint, + int algorithm, + Optional bitStrength, + OffsetDateTime creationTime, + Optional expirationTime, + boolean revoked, + boolean disabled, + List verifiedUids) {} diff --git a/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyRepositoryService.java b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyRepositoryService.java index aca4939..79181da 100644 --- a/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyRepositoryService.java +++ b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/KeyRepositoryService.java @@ -8,6 +8,7 @@ import io.github.bmarwell.keyserver.common.ids.KeyId; import io.github.bmarwell.keyserver.common.ids.PgpPublicKey; import io.github.bmarwell.keyserver.common.ids.RepositoryName; +import java.util.List; import java.util.Optional; /** @@ -30,4 +31,14 @@ public interface KeyRepositoryService { /// @param search HKP search string /// @param exactMatch if true, only exact fingerprint or email matches are returned Optional getArmoredKeyBySearch(String search, boolean exactMatch); + + /// Searches for all matching keys and returns their index metadata for `op=index` responses. + /// + /// Uses the same search routing as {@link #getArmoredKeyBySearch} but returns all matching + /// keys (not just the first) along with algorithm, timestamp, and verified-UID metadata + /// needed to render the HKP machine-readable index format. + /// + /// @param search HKP search string + /// @param exactMatch if true, only exact fingerprint or email matches are returned + List searchForIndex(String search, boolean exactMatch); } diff --git a/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/UidIndexEntry.java b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/UidIndexEntry.java new file mode 100644 index 0000000..770e62a --- /dev/null +++ b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/UidIndexEntry.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2023-2024 The java-keyserver project team. + * + * SPDX-License-Identifier: EUPL-1.2 OR Apache-2.0 + */ +package io.github.bmarwell.keyserver.application.api; + +import java.time.OffsetDateTime; +import java.util.Optional; + +/// Metadata for a single verified UID, used in `op=index` responses. +/// +/// All timestamps are `Optional` because UID packets do not always carry +/// creation/expiration data (older keys often omit them). +public record UidIndexEntry( + String uidRaw, + Optional creationTime, + Optional expirationTime, + boolean revoked) {} diff --git a/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/package-info.java b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/package-info.java new file mode 100644 index 0000000..b9540c4 --- /dev/null +++ b/application/application-api/src/main/java/io/github/bmarwell/keyserver/application/api/package-info.java @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2023-2024 The java-keyserver project team. + * + * SPDX-License-Identifier: EUPL-1.2 OR Apache-2.0 + */ + +/// Primary application port: service interfaces and shared query result types. +/// +/// All types in this package are non-null by default (jspecify `@NullMarked`). +/// Fields and parameters that may be `null` are explicitly annotated with +/// {@link org.jspecify.annotations.Nullable}. +@NullMarked +package io.github.bmarwell.keyserver.application.api; + +import org.jspecify.annotations.NullMarked; diff --git a/application/application-core/src/main/java/io/github/bmarwell/keyserver/application/core/PersistentKeyRepositoryService.java b/application/application-core/src/main/java/io/github/bmarwell/keyserver/application/core/PersistentKeyRepositoryService.java index f64fe2c..dbe22f4 100644 --- a/application/application-core/src/main/java/io/github/bmarwell/keyserver/application/core/PersistentKeyRepositoryService.java +++ b/application/application-core/src/main/java/io/github/bmarwell/keyserver/application/core/PersistentKeyRepositoryService.java @@ -5,6 +5,7 @@ */ package io.github.bmarwell.keyserver.application.core; +import io.github.bmarwell.keyserver.application.api.KeyIndexResult; import io.github.bmarwell.keyserver.application.api.KeyRepositoryService; import io.github.bmarwell.keyserver.application.port.repository.KeyRepository; import io.github.bmarwell.keyserver.common.ids.KeyId; @@ -14,6 +15,7 @@ import jakarta.enterprise.inject.Default; import jakarta.inject.Inject; import java.io.Serializable; +import java.util.List; import java.util.Optional; @Default @@ -38,6 +40,11 @@ public Optional getArmoredKeyBySearch(String search, boolean exactMatch) return keyRepository.findBySearch(search, exactMatch).map(KeyRepository.KeySearchResult::armoredKey); } + @Override + public List searchForIndex(String search, boolean exactMatch) { + return this.keyRepository.findManyBySearch(search, exactMatch); + } + // CDI-friendly setter for unit testing public void setKeyRepository(KeyRepository keyRepository) { this.keyRepository = keyRepository; diff --git a/application/application-core/src/test/java/io/github/bmarwell/keyserver/application/core/cmdhandler/VerifyUidCommandHandlerTest.java b/application/application-core/src/test/java/io/github/bmarwell/keyserver/application/core/cmdhandler/VerifyUidCommandHandlerTest.java index 11e5e4e..c22cf40 100644 --- a/application/application-core/src/test/java/io/github/bmarwell/keyserver/application/core/cmdhandler/VerifyUidCommandHandlerTest.java +++ b/application/application-core/src/test/java/io/github/bmarwell/keyserver/application/core/cmdhandler/VerifyUidCommandHandlerTest.java @@ -63,6 +63,12 @@ public void publishVerifiedUid(String fingerprint, String uidRaw, String uidEmai public Optional findBySearch(String search, boolean exactMatch) { return Optional.empty(); } + + @Override + public List findManyBySearch( + String search, boolean exactMatch) { + return List.of(); + } } /** diff --git a/application/application-ports/application-port-repository/pom.xml b/application/application-ports/application-port-repository/pom.xml index 375b5bd..f59b5e3 100644 --- a/application/application-ports/application-port-repository/pom.xml +++ b/application/application-ports/application-port-repository/pom.xml @@ -13,4 +13,12 @@ 0.1.0-SNAPSHOT Java Keyserver :: application :: port :: repository + + + io.github.bmarwell.keyserver + keyserver-application-api + 0.1.0-SNAPSHOT + + + diff --git a/application/application-ports/application-port-repository/src/main/java/io/github/bmarwell/keyserver/application/port/repository/KeyRepository.java b/application/application-ports/application-port-repository/src/main/java/io/github/bmarwell/keyserver/application/port/repository/KeyRepository.java index a25c9cf..5bf4757 100644 --- a/application/application-ports/application-port-repository/src/main/java/io/github/bmarwell/keyserver/application/port/repository/KeyRepository.java +++ b/application/application-ports/application-port-repository/src/main/java/io/github/bmarwell/keyserver/application/port/repository/KeyRepository.java @@ -5,6 +5,8 @@ */ package io.github.bmarwell.keyserver.application.port.repository; +import io.github.bmarwell.keyserver.application.api.KeyIndexResult; +import java.util.List; import java.util.Optional; /// Secondary (outbound) port for the published key store. @@ -17,7 +19,7 @@ public interface KeyRepository { /// Result type returned by key search queries. /// - /// Contains the minimal data required by the HKP `op=get` and `op=index` responses. + /// Contains the minimal data required by the HKP `op=get` response. /// The `armoredKey` field holds only the verified UIDs (privacy-preserving, per /// {@link #publishVerifiedUid} contract). record KeySearchResult(String fingerprint, String armoredKey) {} @@ -52,4 +54,17 @@ record KeySearchResult(String fingerprint, String armoredKey) {} /// @param search HKP search string /// @param exactMatch when true, only exact fingerprint/email matches are returned Optional findBySearch(String search, boolean exactMatch); + + /// Searches for keys by fingerprint, key ID, email address, or UID substring and returns + /// full index metadata for all matching keys. + /// + /// Uses the same search routing as {@link #findBySearch} but returns all matching keys + /// (not limited to the first) and includes per-key algorithm, timestamp, and verified-UID + /// metadata needed to render HKP `op=index` responses. + /// + /// Only verified UIDs are included in each {@link KeyIndexResult#verifiedUids()} list. + /// + /// @param search HKP search string + /// @param exactMatch when true, only exact fingerprint/email matches are returned + List findManyBySearch(String search, boolean exactMatch); } diff --git a/repository/src/main/java/io/github/bmarwell/keyserver/repository/JpaKeyRepository.java b/repository/src/main/java/io/github/bmarwell/keyserver/repository/JpaKeyRepository.java index 32e7473..ba9bf5a 100644 --- a/repository/src/main/java/io/github/bmarwell/keyserver/repository/JpaKeyRepository.java +++ b/repository/src/main/java/io/github/bmarwell/keyserver/repository/JpaKeyRepository.java @@ -5,6 +5,8 @@ */ package io.github.bmarwell.keyserver.repository; +import io.github.bmarwell.keyserver.application.api.KeyIndexResult; +import io.github.bmarwell.keyserver.application.api.UidIndexEntry; import io.github.bmarwell.keyserver.application.port.repository.KeyRepository; import io.github.bmarwell.keyserver.application.port.repository.KeyRepository.KeySearchResult; import io.github.bmarwell.keyserver.repository.entity.KeyEntity; @@ -12,6 +14,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Default; import jakarta.persistence.LockModeType; +import jakarta.persistence.TypedQuery; import jakarta.transaction.Transactional; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -50,6 +53,32 @@ @ApplicationScoped public class JpaKeyRepository extends BaseRepository implements KeyRepository { + /// Maximum number of keys returned by multi-result index queries. + /// + /// A broad search term (e.g. a common email domain or UID substring) could otherwise + /// load an unbounded number of key rows — together with their UID collections — into a + /// single JPA transaction. 5 000 matches what popular HKP clients are expected to handle + /// and prevents memory exhaustion under adversarial inputs. + private static final int INDEX_RESULT_LIMIT = 5_000; + + // ------------------------------------------------------------------------- + // JPA provider read-only query hints. + // + // Index queries map results directly to immutable DTOs; the entities are + // never written back. Telling each provider to skip dirty tracking and lock + // acquisition reduces heap pressure and eliminates unnecessary lock rows. + // Unknown hints are silently ignored by every compliant JPA provider. + // ------------------------------------------------------------------------- + + /// Hibernate: mark loaded entities as read-only so dirty checking is skipped. + private static final String HINT_HIBERNATE_READ_ONLY = "org.hibernate.readOnly"; + + /// EclipseLink: detach objects immediately after loading (read-only session). + private static final String HINT_ECLIPSELINK_READ_ONLY = "eclipselink.read-only"; + + /// Apache OpenJPA: acquire no read lock, reducing contention on index queries. + private static final String HINT_OPENJPA_READ_LOCK_MODE = "openjpa.FetchPlan.ReadLockMode"; + @Override @Transactional public void publishVerifiedUid(String fingerprint, String uidRaw, String uidEmail, String armoredKey) { @@ -151,6 +180,30 @@ public Optional findBySearch(String search, boolean exactMatch) return findByUidSubstring(normalized); } + @Override + @Transactional + public List findManyBySearch(String search, boolean exactMatch) { + if (search == null || search.isBlank()) { + return List.of(); + } + + String normalized = search.strip(); + + String hexCandidate = normalized.startsWith("0x") ? normalized.substring(2) : normalized; + if ((normalized.startsWith("0x") || isHexString(hexCandidate)) && isValidKeyIdLength(hexCandidate.length())) { + return findManyByKeyIdOrFingerprint(hexCandidate); + } + + if (normalized.contains("@")) { + return findManyByEmail(normalized.toLowerCase(Locale.ROOT), exactMatch); + } + + if (exactMatch) { + return List.of(); + } + return findManyByUidSubstring(normalized); + } + private Optional findByKeyIdOrFingerprint(String hexValue) { // Normalise to uppercase: fingerprint (and the DB-generated keyid_long column) are stored // as uppercase. Avoiding LOWER() on the column allows the DB to use the keys_keyid_long @@ -159,12 +212,10 @@ private Optional findByKeyIdOrFingerprint(String hexValue) { int len = upper.length(); if (len == 40 || len == 64) { - // Full fingerprint — primary key lookup (always indexed). return toResult(getEntityManager().find(KeyEntity.class, upper)); } if (len == 16) { - // Long key ID: exact match on the generated keyid_long column (uses keys_keyid_long index). List results = getEntityManager() .createQuery("SELECT k FROM KeyEntity k WHERE k.keyidLong = :keyId", KeyEntity.class) .setParameter("keyId", upper) @@ -174,8 +225,7 @@ private Optional findByKeyIdOrFingerprint(String hexValue) { } // Short key ID (8 chars): reverse it and use the rfingerprint text_pattern_ops index for a - // prefix scan. reverse(fingerprint) starts with reverse(shortKeyId), so this matches the - // last 8 chars of the fingerprint without a leading-wildcard LIKE on the forward column. + // prefix scan. String reversedShortId = new StringBuilder(upper).reverse().toString(); List results = getEntityManager() .createQuery("SELECT k FROM KeyEntity k WHERE k.rfingerprint LIKE :prefix", KeyEntity.class) @@ -185,31 +235,147 @@ private Optional findByKeyIdOrFingerprint(String hexValue) { return results.isEmpty() ? Optional.empty() : toResult(results.get(0)); } + private List findManyByKeyIdOrFingerprint(String hexValue) { + String upper = hexValue.toUpperCase(Locale.ROOT); + int len = upper.length(); + + if (len == 40 || len == 64) { + // Full fingerprint — switch from em.find() to JPQL so JOIN FETCH eagerly loads UIDs. + List results = applyReadOnlyHints(getEntityManager() + .createQuery( + "SELECT DISTINCT k FROM KeyEntity k JOIN FETCH k.uids" + + " WHERE k.fingerprint = :fp", + KeyEntity.class) + .setParameter("fp", upper)) + .getResultList(); + return toIndexResults(results); + } + + if (len == 16) { + List results = applyReadOnlyHints(getEntityManager() + .createQuery( + "SELECT DISTINCT k FROM KeyEntity k JOIN FETCH k.uids" + + " WHERE k.keyidLong = :keyId", + KeyEntity.class) + .setParameter("keyId", upper)) + .getResultList(); + return toIndexResults(results); + } + + // Short key ID (8 chars): reverse it and use the rfingerprint text_pattern_ops index for a + // prefix scan. reverse(fingerprint) starts with reverse(shortKeyId), so this matches the + // last 8 chars of the fingerprint without a leading-wildcard LIKE on the forward column. + String reversedShortId = new StringBuilder(upper).reverse().toString(); + List results = applyReadOnlyHints(getEntityManager() + .createQuery( + "SELECT DISTINCT k FROM KeyEntity k JOIN FETCH k.uids" + + " WHERE k.rfingerprint LIKE :prefix", + KeyEntity.class) + .setParameter("prefix", reversedShortId + "%")) + .getResultList(); + return toIndexResults(results); + } + private Optional findByEmail(String email, boolean exactMatch) { + List results = queryEntitiesByEmail(email, exactMatch, 1); + return results.isEmpty() ? Optional.empty() : toResult(results.get(0)); + } + + private List findManyByEmail(String email, boolean exactMatch) { + List fingerprints = queryFingerprintsByEmail(email, exactMatch); + return fingerprints.isEmpty() ? List.of() : fetchIndexResultsByFingerprints(fingerprints); + } + + /// Returns at most one KeyEntity row for a given email filter (single-result path). + /// + /// Keeps the JPQL in one place; callers must always pass {@code maxResults = 1}. + /// The multi-result path uses {@link #queryFingerprintsByEmail} instead. + private List queryEntitiesByEmail(String email, boolean exactMatch, int maxResults) { String jpql = exactMatch ? "SELECT DISTINCT k FROM KeyEntity k JOIN k.uids u" + " WHERE LOWER(u.uidEmail) = :email AND u.verified = true" : "SELECT DISTINCT k FROM KeyEntity k JOIN k.uids u" + " WHERE LOWER(u.uidEmail) LIKE :email AND u.verified = true"; String param = exactMatch ? email : "%" + email + "%"; - List results = getEntityManager() + return getEntityManager() .createQuery(jpql, KeyEntity.class) .setParameter("email", param) - .setMaxResults(1) + .setMaxResults(maxResults) + .getResultList(); + } + + /// First query of the two-query pattern for email index searches. + /// + /// Selects fingerprints only (no collection fetch) so that {@link #INDEX_RESULT_LIMIT} + /// is applied correctly at the SQL level. The caller then passes the fingerprint list + /// to {@link #fetchIndexResultsByFingerprints} which loads the full entity graph + /// via {@code JOIN FETCH} without any conflicting pagination. + private List queryFingerprintsByEmail(String email, boolean exactMatch) { + String jpql = exactMatch + ? "SELECT DISTINCT k.fingerprint FROM KeyEntity k JOIN k.uids u" + + " WHERE LOWER(u.uidEmail) = :email AND u.verified = true" + : "SELECT DISTINCT k.fingerprint FROM KeyEntity k JOIN k.uids u" + + " WHERE LOWER(u.uidEmail) LIKE :email AND u.verified = true"; + String param = exactMatch ? email : "%" + email + "%"; + return applyReadOnlyHints(getEntityManager() + .createQuery(jpql, String.class) + .setParameter("email", param) + .setMaxResults(INDEX_RESULT_LIMIT)) .getResultList(); - return results.isEmpty() ? Optional.empty() : toResult(results.get(0)); } private Optional findByUidSubstring(String term) { - List results = getEntityManager() + List results = queryEntitiesByUidSubstring(term, 1); + return results.isEmpty() ? Optional.empty() : toResult(results.get(0)); + } + + private List findManyByUidSubstring(String term) { + List fingerprints = queryFingerprintsByUidSubstring(term); + return fingerprints.isEmpty() ? List.of() : fetchIndexResultsByFingerprints(fingerprints); + } + + /// Returns at most one KeyEntity row whose raw UID text contains the given term (single-result path). + /// + /// Callers must always pass {@code maxResults = 1}. + /// The multi-result path uses {@link #queryFingerprintsByUidSubstring} instead. + private List queryEntitiesByUidSubstring(String term, int maxResults) { + return getEntityManager() .createQuery( "SELECT DISTINCT k FROM KeyEntity k JOIN k.uids u" + " WHERE LOWER(u.uidRaw) LIKE :term AND u.verified = true", KeyEntity.class) .setParameter("term", "%" + term.toLowerCase(Locale.ROOT) + "%") - .setMaxResults(1) + .setMaxResults(maxResults) + .getResultList(); + } + + /// First query of the two-query pattern for UID substring index searches. + /// + /// Selects fingerprints only so {@link #INDEX_RESULT_LIMIT} is applied at the SQL level. + private List queryFingerprintsByUidSubstring(String term) { + return applyReadOnlyHints(getEntityManager() + .createQuery( + "SELECT DISTINCT k.fingerprint FROM KeyEntity k JOIN k.uids u" + + " WHERE LOWER(u.uidRaw) LIKE :term AND u.verified = true", + String.class) + .setParameter("term", "%" + term.toLowerCase(Locale.ROOT) + "%") + .setMaxResults(INDEX_RESULT_LIMIT)) .getResultList(); - return results.isEmpty() ? Optional.empty() : toResult(results.get(0)); + } + + /// Second query of the two-query pattern: loads full key entities with their UIDs eagerly. + /// + /// Because the fingerprint list was already bounded by {@link #INDEX_RESULT_LIMIT} in the + /// first query, this {@code JOIN FETCH} query has no conflicting pagination and the JPA + /// provider can apply the join at the SQL level without in-memory row reduction. + private List fetchIndexResultsByFingerprints(List fingerprints) { + List entities = applyReadOnlyHints(getEntityManager() + .createQuery( + "SELECT DISTINCT k FROM KeyEntity k JOIN FETCH k.uids" + " WHERE k.fingerprint IN :fps", + KeyEntity.class) + .setParameter("fps", fingerprints)) + .getResultList(); + return toIndexResults(entities); } private static Optional toResult(KeyEntity key) { @@ -219,6 +385,51 @@ private static Optional toResult(KeyEntity key) { return Optional.of(new KeySearchResult(key.getFingerprint(), key.getArmoredKey())); } + private static List toIndexResults(List keys) { + return keys.stream() + .map(JpaKeyRepository::toIndexResult) + .filter(r -> !r.verifiedUids().isEmpty()) + .toList(); + } + + private static KeyIndexResult toIndexResult(KeyEntity key) { + List uids = key.getUids().stream() + .filter(UidEntity::isVerified) + .map(u -> new UidIndexEntry(u.getUidRaw(), u.getCreationTime(), u.getExpirationTime(), u.isRevoked())) + .toList(); + + return new KeyIndexResult( + key.getFingerprint(), + key.getAlgorithm(), + key.getBitStrength(), + key.getCreationTime(), + key.getExpirationTime(), + key.isRevoked(), + key.isDisabled(), + uids); + } + + /// Applies read-only query hints for the three major JPA providers. + /// + /// Index queries map results directly to immutable {@link KeyIndexResult} DTOs — + /// the loaded entities are never modified. Signalling read-only intent allows each + /// provider to skip dirty tracking and lock acquisition: + ///
    + ///
  • Hibernate ({@code org.hibernate.readOnly = true}): skips snapshot creation and + /// dirty checking at flush time.
  • + ///
  • EclipseLink ({@code eclipselink.read-only = true}): detaches objects immediately, + /// preventing them from entering the identity map.
  • + ///
  • Apache OpenJPA ({@code openjpa.FetchPlan.ReadLockMode = "NONE"}): suppresses + /// implicit read-lock acquisition on each loaded row.
  • + ///
+ /// Unknown hints are silently ignored by all compliant JPA providers, so this method + /// is safe to call regardless of which provider is active at runtime. + private static TypedQuery applyReadOnlyHints(TypedQuery query) { + return query.setHint(HINT_HIBERNATE_READ_ONLY, Boolean.TRUE) + .setHint(HINT_ECLIPSELINK_READ_ONLY, Boolean.TRUE) + .setHint(HINT_OPENJPA_READ_LOCK_MODE, "NONE"); + } + private static boolean isHexString(String s) { if (s.isEmpty()) { return false; diff --git a/repository/src/main/java/io/github/bmarwell/keyserver/repository/entity/KeyEntity.java b/repository/src/main/java/io/github/bmarwell/keyserver/repository/entity/KeyEntity.java index a289e6f..551a9e5 100644 --- a/repository/src/main/java/io/github/bmarwell/keyserver/repository/entity/KeyEntity.java +++ b/repository/src/main/java/io/github/bmarwell/keyserver/repository/entity/KeyEntity.java @@ -59,6 +59,12 @@ public class KeyEntity { @Column(name = "revoked", nullable = false) private boolean revoked; + /// Keyserver-administrative flag: true if a keyserver operator has explicitly + /// disabled this key. Unlike {@code revoked}, this flag is not set by the key + /// owner and is not reflected in the OpenPGP key material. + @Column(name = "disabled", nullable = false) + private boolean disabled; + /// Full ASCII-armored public key block returned verbatim on `op=get`. @Column(name = "armored_key", nullable = false) private String armoredKey; @@ -143,6 +149,15 @@ public void setRevoked(boolean revoked) { this.mtime = OffsetDateTime.now(); } + public boolean isDisabled() { + return disabled; + } + + public void setDisabled(boolean disabled) { + this.disabled = disabled; + this.mtime = OffsetDateTime.now(); + } + public String getArmoredKey() { return armoredKey; } diff --git a/repository/src/main/resources/io/github/bmarwell/keyserver/repository/migrations/V010__add-disabled-flag-to-keys.sql b/repository/src/main/resources/io/github/bmarwell/keyserver/repository/migrations/V010__add-disabled-flag-to-keys.sql new file mode 100644 index 0000000..4246c04 --- /dev/null +++ b/repository/src/main/resources/io/github/bmarwell/keyserver/repository/migrations/V010__add-disabled-flag-to-keys.sql @@ -0,0 +1,5 @@ +-- Add keyserver-level 'disabled' flag to the keys table. +-- A disabled key is shown in op=index results with the 'd' flag but is not revoked +-- in the OpenPGP sense; the flag is set by keyserver administrators only. +-- Defaults to FALSE so all existing keys remain visible after the migration. +ALTER TABLE keys ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/HkpIndexRenderer.java b/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/HkpIndexRenderer.java new file mode 100644 index 0000000..f0b8879 --- /dev/null +++ b/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/HkpIndexRenderer.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2023-2024 The java-keyserver project team. + * + * SPDX-License-Identifier: EUPL-1.2 OR Apache-2.0 + */ +package io.github.bmarwell.keyserver.web.pks; + +import io.github.bmarwell.keyserver.application.api.KeyIndexResult; +import io.github.bmarwell.keyserver.application.api.UidIndexEntry; +import jakarta.enterprise.context.ApplicationScoped; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +/// Renders HKP `op=index` responses in machine-readable and HTML formats. +/// +/// Machine-readable format (`options=mr`) follows the HKP draft specification: +/// each key block starts with a `pub:` line containing fingerprint, algorithm, key length, +/// creation timestamp, expiration timestamp, and flags. Each verified UID follows as a +/// `uid:` line. The response begins with an `info:` header line. +/// +/// The HTML format is a simple table — sufficient for browser inspection but not required +/// for GnuPG interoperability. +@ApplicationScoped +public class HkpIndexRenderer { + + /// Renders the machine-readable HKP index format (`options=mr`). + /// + /// Format per the HKP draft: + ///
+    /// info:1:<count>
+    /// pub:<fingerprint>:<algo>:<keylen>:<ctime>:<exptime>:<flags>
+    /// uid:<pct-encoded-uid>:<ctime>:<exptime>:<flags>
+    /// 
+ /// + /// Flags (in spec order): `r` if revoked, `d` if disabled on this keyserver, + /// `e` if expired (expiration is in the past), or empty. + /// Key length is empty for ECC keys (bit strength is null), numeric for RSA/DSA. + /// The `d` flag is a keyserver-administrative concept; it does not appear on + /// `uid:` lines since UIDs are not disabled independently. + public String renderMachineReadable(List results) { + Instant now = Instant.now(); + StringBuilder sb = new StringBuilder(); + sb.append("info:1:").append(results.size()).append('\n'); + for (KeyIndexResult key : results) { + sb.append("pub:"); + sb.append(key.fingerprint()).append(':'); + sb.append(key.algorithm()).append(':'); + key.bitStrength().ifPresent(bs -> sb.append(bs)); + sb.append(':'); + sb.append(toEpochSeconds(key.creationTime())).append(':'); + sb.append(key.expirationTime().map(HkpIndexRenderer::toEpochSeconds).orElse("")) + .append(':'); + sb.append(computeFlags(key.revoked(), key.disabled(), key.expirationTime(), now)); + sb.append('\n'); + for (UidIndexEntry uid : key.verifiedUids()) { + sb.append("uid:"); + // Use %20 for spaces (RFC 3986 percent-encoding) rather than +. + // URLEncoder uses application/x-www-form-urlencoded which encodes spaces as '+', + // but '+' is a legal literal character in UID strings and would be mis-decoded + // by strict HKP clients expecting RFC 3986. + sb.append(URLEncoder.encode(uid.uidRaw(), StandardCharsets.UTF_8) + .replace("+", "%20")) + .append(':'); + sb.append(uid.creationTime() + .map(HkpIndexRenderer::toEpochSeconds) + .orElse("")) + .append(':'); + sb.append(uid.expirationTime() + .map(HkpIndexRenderer::toEpochSeconds) + .orElse("")) + .append(':'); + // UIDs are not disabled independently; pass false for the 'd' flag. + sb.append(computeFlags(uid.revoked(), false, uid.expirationTime(), now)); + sb.append('\n'); + } + } + return sb.toString(); + } + + /// Renders a simple HTML table for browser display. + /// + /// The HTML is intentionally minimal — it is not required for GnuPG interoperability + /// and is provided as a human-readable fallback only. + public String renderHtml(List results) { + Instant now = Instant.now(); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("Key search results"); + sb.append("

Search results: ").append(results.size()).append(" key(s)

\n"); + sb.append("" + + "\n"); + for (KeyIndexResult key : results) { + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append(""); + sb.append("\n"); + } + sb.append("
FingerprintAlgorithm (OpenPGP code)CreatedExpiresFlagsUIDs
").append(htmlEscape(key.fingerprint())).append("").append(key.algorithm()).append("").append(key.creationTime()).append("") + .append(key.expirationTime().map(Object::toString).orElse("")) + .append("") + .append(computeFlags(key.revoked(), key.disabled(), key.expirationTime(), now)) + .append(""); + for (UidIndexEntry uid : key.verifiedUids()) { + sb.append(htmlEscape(uid.uidRaw())).append("
"); + } + sb.append("
"); + return sb.toString(); + } + + private static String toEpochSeconds(OffsetDateTime dt) { + return String.valueOf(dt.toEpochSecond()); + } + + private static String computeFlags( + boolean revoked, boolean disabled, Optional expiration, Instant now) { + StringBuilder flags = new StringBuilder(); + if (revoked) { + flags.append('r'); + } + if (disabled) { + flags.append('d'); + } + if (expiration.isPresent() && expiration.get().toInstant().isBefore(now)) { + flags.append('e'); + } + return flags.toString(); + } + + private static String htmlEscape(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/LookupEndpoint.java b/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/LookupEndpoint.java index f9d83ed..976dbc3 100644 --- a/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/LookupEndpoint.java +++ b/web/openpgp-keyserver-protocol/src/main/java/io/github/bmarwell/keyserver/web/pks/LookupEndpoint.java @@ -5,19 +5,22 @@ */ package io.github.bmarwell.keyserver.web.pks; +import io.github.bmarwell.keyserver.application.api.KeyIndexResult; import io.github.bmarwell.keyserver.application.api.KeyRepositoryService; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Response; +import java.util.Arrays; +import java.util.List; import java.util.Optional; import org.eclipse.microprofile.openapi.annotations.tags.Tag; /// HKP `/pks/lookup` endpoint. /// -/// Handles `op=get` (return ASCII-armored key block) and stubs `op=index`/`op=vindex` -/// (machine-readable listing — future work). +/// Handles `op=get` (return ASCII-armored key block) and `op=index` +/// (machine-readable or HTML key listing). /// /// Only keys with at least one verified UID are returned. Search by fingerprint /// (`0x`), long/short key ID, email address, or UID substring. @@ -28,9 +31,15 @@ public class LookupEndpoint { @Inject KeyRepositoryService keyRepositoryService; + @Inject + HkpIndexRenderer hkpIndexRenderer; + @GET public Response doLookup( - @QueryParam("op") String op, @QueryParam("search") String search, @QueryParam("exact") String exact) { + @QueryParam("op") String op, + @QueryParam("search") String search, + @QueryParam("exact") String exact, + @QueryParam("options") String options) { // Both null/blank op and search are invalid — HKP requires both to be present. // Report exactly which parameter(s) are missing so the client can self-correct. @@ -50,7 +59,13 @@ public Response doLookup( return handleGet(search, exactMatch); } - // op=index and op=vindex are future work + if ("index".equalsIgnoreCase(op)) { + boolean machineReadable = options != null + && Arrays.stream(options.split(",")).map(String::strip).anyMatch("mr"::equalsIgnoreCase); + return handleIndex(search, exactMatch, machineReadable); + } + + // op=vindex and unknown ops return Response.status(Response.Status.NOT_IMPLEMENTED) .entity("op=" + op + " is not yet implemented") .type("text/plain") @@ -73,8 +88,29 @@ private Response handleGet(String search, boolean exactMatch) { .build(); } + private Response handleIndex(String search, boolean exactMatch, boolean machineReadable) { + List results = this.keyRepositoryService.searchForIndex(search, exactMatch); + if (results.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity("No keys found for the provided search term") + .type("text/plain") + .build(); + } + if (machineReadable) { + String body = this.hkpIndexRenderer.renderMachineReadable(results); + return Response.ok(body).type("text/plain; charset=utf-8").build(); + } + String body = this.hkpIndexRenderer.renderHtml(results); + return Response.ok(body).type("text/html; charset=utf-8").build(); + } + // CDI-friendly setter for unit testing public void setKeyRepositoryService(KeyRepositoryService keyRepositoryService) { this.keyRepositoryService = keyRepositoryService; } + + // CDI-friendly setter for unit testing + public void setHkpIndexRenderer(HkpIndexRenderer hkpIndexRenderer) { + this.hkpIndexRenderer = hkpIndexRenderer; + } } diff --git a/web/openpgp-keyserver-protocol/src/test/java/io/github/bmarwell/keyserver/web/pks/LookupEndpointIndexTest.java b/web/openpgp-keyserver-protocol/src/test/java/io/github/bmarwell/keyserver/web/pks/LookupEndpointIndexTest.java new file mode 100644 index 0000000..3b846f8 --- /dev/null +++ b/web/openpgp-keyserver-protocol/src/test/java/io/github/bmarwell/keyserver/web/pks/LookupEndpointIndexTest.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2023-2024 The java-keyserver project team. + * + * SPDX-License-Identifier: EUPL-1.2 OR Apache-2.0 + */ +package io.github.bmarwell.keyserver.web.pks; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.bmarwell.keyserver.application.api.KeyIndexResult; +import io.github.bmarwell.keyserver.application.api.KeyRepositoryService; +import io.github.bmarwell.keyserver.application.api.UidIndexEntry; +import jakarta.ws.rs.core.Response; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LookupEndpointIndexTest { + + private static final String FINGERPRINT = "A1B2C3D4E5F60708091011121314151617181920A1B2"; + private static final OffsetDateTime CREATION = OffsetDateTime.of(2024, 1, 15, 12, 0, 0, 0, ZoneOffset.UTC); + private static final String UID_RAW = "Alice "; + + private LookupEndpoint endpoint; + private FakeKeyRepositoryService fakeService; + + @BeforeEach + void setUp() { + this.fakeService = new FakeKeyRepositoryService(); + this.endpoint = new LookupEndpoint(); + this.endpoint.setKeyRepositoryService(this.fakeService); + this.endpoint.setHkpIndexRenderer(new HkpIndexRenderer()); + } + + @Test + void returnsNotFoundWhenNoKeyMatchesIndexSearch() { + // given — the repository contains no keys matching the search + this.fakeService.indexResults = List.of(); + + // when — the client sends op=index with options=mr + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + + // then — a 404 is returned so GnuPG knows no key was found + assertThat(response.getStatus()) + .as("empty index results must yield 404 so HKP clients know no key was found") + .isEqualTo(404); + } + + @Test + void rendersMachineReadableInfoLineWithKeyCount() { + // given — one key with one verified UID in the repository + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.empty(), false, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — the client sends op=index&options=mr (machine-readable) + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + String body = (String) response.getEntity(); + + // then — the info header must contain the key count so GnuPG can allocate result structures + assertThat(response.getStatus()).isEqualTo(200); + assertThat(body) + .as("machine-readable response must start with info:1: per HKP draft spec") + .startsWith("info:1:1\n"); + } + + @Test + void rendersMachineReadablePubLine() { + // given — one RSA-2048 key (algorithm 1, bitStrength 2048) with a known creation time + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 1, Optional.of(2048), CREATION, Optional.empty(), false, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client requests machine-readable index + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + String body = (String) response.getEntity(); + + // then — pub: line must carry fingerprint, algorithm, keylen, epoch seconds, expiry (empty), and flags + // The format is required by GnuPG for trust lookups: missing fields break key import + assertThat(body) + .as("pub line must contain fingerprint and algorithm for GnuPG to import the key") + .contains("pub:" + FINGERPRINT + ":1:2048:" + CREATION.toEpochSecond() + "::\n"); + } + + @Test + void rendersMachineReadableUidLinePercentEncoded() { + // given — a UID string containing characters that need percent-encoding ('<', '>', ' ') + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.empty(), false, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client requests machine-readable index + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + String body = (String) response.getEntity(); + + // then — UID must be percent-encoded using %20 for spaces (RFC 3986), not + (form-encoding) + // '+' is a legal literal character in UID strings and would be mis-decoded by strict clients + assertThat(body) + .as("UID must use %20 for spaces, not '+', because '+' is legal in UID strings and" + + " would be mis-decoded by strict RFC 3986 percent-encoding clients") + .contains("uid:Alice%20%3Calice%40example.com%3E:"); + } + + @Test + void setsFlagRForRevokedKey() { + // given — a revoked key without expiration + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.empty(), true, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client requests machine-readable index + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + String body = (String) response.getEntity(); + + // then — the pub: flags field must contain 'r' so HKP clients display it as revoked + // format: pub:::::: + assertThat(body) + .as("revoked key must have 'r' in flags so HKP clients display it as revoked") + .contains("pub:" + FINGERPRINT + ":22::" + CREATION.toEpochSecond() + "::r\n"); + } + + @Test + void setsFlagEForExpiredKey() { + // given — a key whose expiration time is clearly in the past (year 2000) + OffsetDateTime pastExpiry = OffsetDateTime.of(2000, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.of(pastExpiry), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.of(pastExpiry), false, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client requests machine-readable index + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + String body = (String) response.getEntity(); + + // then — the pub: flags field (not just the uid: flags) must contain 'e' + // format: pub:::::: + // Asserting the full pub: line ensures we test the key-level flag specifically, + // not just the uid: line which would also show 'e' for the same expiry + assertThat(body) + .as("expired key must have 'e' in flags on the pub: line so GnuPG knows not to use it for encryption") + .contains("pub:" + FINGERPRINT + ":22::" + CREATION.toEpochSecond() + ":" + pastExpiry.toEpochSecond() + + ":e\n"); + } + + @Test + void rendersHtmlWhenOptionsMrAbsent() { + // given — one key in the repository, no options=mr in the request + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.empty(), false, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client sends op=index without options=mr (browser request) + Response response = this.endpoint.doLookup("index", "alice@example.com", null, null); + String body = (String) response.getEntity(); + + // then — response must be HTML so it can be displayed in a browser for human inspection + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString("Content-Type")) + .as("browser requests (no options=mr) must receive HTML for human readability") + .startsWith("text/html"); + assertThat(body) + .as("HTML body must contain the fingerprint so users can identify the key") + .contains(FINGERPRINT); + assertThat(body) + .as("HTML must include DOCTYPE and charset meta so browsers render it in standards mode with UTF-8") + .startsWith(""); + assertThat(body).contains(""); + } + + @Test + void recognisesMrTokenAmongCommaDelimitedOptions() { + // given — options contains 'mr' alongside another token + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.empty(), false, false, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client sends options=nm,mr (comma-separated list) + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "nm,mr"); + String body = (String) response.getEntity(); + + // then — machine-readable format must be returned because 'mr' is in the options list + assertThat(response.getStatus()).isEqualTo(200); + assertThat(body) + .as("'mr' token in a comma-separated options list must trigger machine-readable output") + .startsWith("info:1:"); + } + + @Test + void setsFlagDForDisabledKey() { + // given — a key that has been disabled on this keyserver (not OpenPGP-revoked) + UidIndexEntry uid = new UidIndexEntry(UID_RAW, Optional.of(CREATION), Optional.empty(), false); + KeyIndexResult key = new KeyIndexResult( + FINGERPRINT, 22, Optional.empty(), CREATION, Optional.empty(), false, true, List.of(uid)); + this.fakeService.indexResults = List.of(key); + + // when — client requests machine-readable index + Response response = this.endpoint.doLookup("index", "alice@example.com", null, "mr"); + String body = (String) response.getEntity(); + + // then — the pub: flags field must contain 'd' (not 'r') since the key is disabled, not revoked + // format: pub:::::: + assertThat(body) + .as("keyserver-disabled key must have 'd' in pub: flags; 'r' is reserved for OpenPGP revocation") + .contains("pub:" + FINGERPRINT + ":22::" + CREATION.toEpochSecond() + "::d\n"); + } + + // --------------------------------------------------------------------------- + // Test double — avoids CDI/Mockito overhead; keeps the test focused on the + // endpoint's routing and response-building logic only + // --------------------------------------------------------------------------- + + private static final class FakeKeyRepositoryService implements KeyRepositoryService { + + List indexResults = List.of(); + + @Override + public Optional getArmoredKeyBySearch(String search, boolean exactMatch) { + return Optional.empty(); + } + + @Override + public List searchForIndex(String search, boolean exactMatch) { + return this.indexResults; + } + + @Override + public void getKeyByRepoAndKeyId( + io.github.bmarwell.keyserver.common.ids.RepositoryName repoName, + io.github.bmarwell.keyserver.common.ids.KeyId keyId) { + throw new UnsupportedOperationException("not needed in this test"); + } + + @Override + public Optional getKeyByKeyId( + io.github.bmarwell.keyserver.common.ids.KeyId keyId) { + return Optional.empty(); + } + } +}