commit b142f310be392a0c0f6e5ca71ab195a5390ad29f Author: Paul Schaub Date: Tue Mar 1 15:19:01 2022 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84123d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2021 Paul Schaub +# +# SPDX-License-Identifier: CC0-1.0 + +.idea +.gradle + +out/ +build/ +bin/ +libs/ + +*.iws +*.iml +*.ipr +*.class +*.log +*.jar + +gradle.properties +!gradle-wrapper.jar + +.classpath +.project +.settings/ + +pgpainless-core/.classpath +pgpainless-core/.project +pgpainless-core/.settings/ + +push_html.sh diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..0631047 --- /dev/null +++ b/build.gradle @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +buildscript { + + repositories { + + maven { + url "https://plugins.gradle.org/m2/" + } + mavenLocal() + mavenCentral() + } + dependencies { + classpath "gradle.plugin.org.kt3k.gradle.plugin:coveralls-gradle-plugin:2.12.0" + } +} + +plugins { + id 'ru.vyarus.animalsniffer' version '1.5.3' +} + +apply from: 'version.gradle' + +allprojects { + apply plugin: 'java' + apply plugin: 'idea' + apply plugin: 'eclipse' + apply plugin: 'jacoco' + apply plugin: 'checkstyle' + + // Only generate jar for submodules + // without this we would generate an empty .jar for the project root + // https://stackoverflow.com/a/25445035 + jar { + onlyIf { !sourceSets.main.allSource.files.isEmpty() } + } + + // For library modules, enable android api compatibility check + if (it.name != 'cli') { + // animalsniffer + apply plugin: 'ru.vyarus.animalsniffer' + dependencies { + signature "net.sf.androidscents.signature:android-api-level-${minAndroidSdk}:2.3.3_r2@signature" + } + animalsniffer { + sourceSets = [sourceSets.main] + } + } + + // checkstyle + checkstyle { + toolVersion = '8.18' + } + + group 'org.pgpainless' + description = "Shared PGP Certificate Directory for Java" + version = shortVersion + + sourceCompatibility = javaSourceCompatibility + + repositories { + mavenCentral() + } + + // Reproducible Builds + tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true + } + + project.ext { + slf4jVersion = '1.7.32' + logbackVersion = '1.2.9' + junitVersion = '5.8.2' + sopJavaVersion = '1.2.0' + rootConfigDir = new File(rootDir, 'config') + gitCommit = getGitCommit() + isContinuousIntegrationEnvironment = Boolean.parseBoolean(System.getenv('CI')) + isReleaseVersion = !isSnapshot + signingRequired = !(isSnapshot || isContinuousIntegrationEnvironment) + sonatypeCredentialsAvailable = project.hasProperty('sonatypeUsername') && project.hasProperty('sonatypePassword') + sonatypeSnapshotUrl = 'https://oss.sonatype.org/content/repositories/snapshots' + sonatypeStagingUrl = 'https://oss.sonatype.org/service/local/staging/deploy/maven2' + } + + if (isSnapshot) { + version = version + '-SNAPSHOT' + } + def projectDirFile = new File("$projectDir") + if (!project.ext.isSnapshot && !'git describe --exact-match HEAD'.execute(null, projectDirFile).text.trim().equals(ext.shortVersion)) { + throw new InvalidUserDataException('Untagged version detected! Please tag every release.') + } + if (!version.endsWith('-SNAPSHOT') && version != 'git tag --points-at HEAD'.execute(null, projectDirFile).text.trim()) { + throw new InvalidUserDataException( + 'Tag mismatch detected, version is ' + version + ' but should be ' + + 'git tag --points-at HEAD'.execute(null, projectDirFile).text.trim()) + } + + jacoco { + toolVersion = "0.8.7" + } + + jacocoTestReport { + dependsOn test + sourceDirectories.setFrom(project.files(sourceSets.main.allSource.srcDirs)) + classDirectories.setFrom(project.files(sourceSets.main.output)) + reports { + xml.enabled true + } + } + + test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } + } +} + +subprojects { + apply plugin: 'maven-publish' + apply plugin: 'signing' + + task sourcesJar(type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource + } + task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir + } + task testsJar(type: Jar, dependsOn: testClasses) { + classifier = 'tests' + from sourceSets.test.output + } + + publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + artifact testsJar + pom { + name = 'PGPainless' + description = 'Simple to use OpenPGP API for Java based on Bouncycastle' + url = 'https://github.com/pgpainless/pgpainless' + inceptionYear = '2018' + + scm { + url = 'https://github.com/pgpainless/pgpainless' + connection = 'scm:https://github.com/pgpainless/pgpainless' + developerConnection = 'scm:git://github.com/pgpainless/pgpainless.git' + } + + licenses { + license { + name = 'The Apache Software License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution = 'repo' + } + } + + developers { + developer { + id = 'vanitasvitae' + name = 'Paul Schaub' + email = 'vanitasvitae@fsfe.org' + } + } + } + } + } + repositories { + if (sonatypeCredentialsAvailable) { + maven { + url isSnapshot ? sonatypeSnapshotUrl : sonatypeStagingUrl + credentials { + username = sonatypeUsername + password = sonatypePassword + } + } + } + } + } + + signing { + useGpgCmd() + required { signingRequired } + sign publishing.publications.mavenJava + } +} + +def getGitCommit() { + def projectDirFile = new File("$projectDir") + def dotGit = new File("$projectDir/.git") + if (!dotGit.isDirectory()) return 'non-git build' + + def cmd = 'git describe --always --tags --dirty=+' + def proc = cmd.execute(null, projectDirFile) + def gitCommit = proc.text.trim() + assert !gitCommit.isEmpty() + + def srCmd = 'git symbolic-ref --short HEAD' + def srProc = srCmd.execute(null, projectDirFile) + srProc.waitForOrKill(10 * 1000) + if (srProc.exitValue() == 0) { + // Only add the information if the git command was + // successful. There may be no symbolic reference for HEAD if + // e.g. in detached mode. + def symbolicReference = srProc.text.trim() + assert !symbolicReference.isEmpty() + gitCommit += "-$symbolicReference" + } + + gitCommit +} + +apply plugin: "com.github.kt3k.coveralls" +coveralls { + sourceDirs = files(subprojects.sourceSets.main.allSource.srcDirs).files.absolutePath +} + +task jacocoRootReport(type: JacocoReport) { + dependsOn = subprojects.jacocoTestReport + sourceDirectories.setFrom(files(subprojects.sourceSets.main.allSource.srcDirs)) + classDirectories.setFrom(files(subprojects.sourceSets.main.output)) + executionData.setFrom(files(subprojects.jacocoTestReport.executionData)) + reports { + xml.enabled true + xml.destination file("${buildDir}/reports/jacoco/test/jacocoTestReport.xml") + } + // We could remove the following setOnlyIf line, but then + // jacocoRootReport would silently be SKIPPED if something with + // the projectsWithUnitTests is wrong (e.g. a project is missing + // in there). + setOnlyIf { true } +} + +task javadocAll(type: Javadoc) { + def currentJavaVersion = JavaVersion.current() + if (currentJavaVersion.compareTo(JavaVersion.VERSION_1_9) >= 0) { + options.addStringOption("-release", "8"); + } + source subprojects.collect {project -> + project.sourceSets.main.allJava } + destinationDir = new File(buildDir, 'javadoc') + // Might need a classpath + classpath = files(subprojects.collect {project -> + project.sourceSets.main.compileClasspath}) + options.linkSource = true + options.use = true + options.links = [ + "https://docs.oracle.com/javase/${sourceCompatibility.getMajorVersion()}/docs/api/", + ] as String[] +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..06e167f --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 0000000..1314d44 --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/build.gradle b/pgp-cert-d-java-jdbc-sqlite-lookup/build.gradle new file mode 100644 index 0000000..48e37bc --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/build.gradle @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id 'java-library' +} + +group 'org.pgpainless' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Logging + testImplementation "ch.qos.logback:logback-classic:$logbackVersion" + + implementation project(":pgp-cert-d-java") + api 'org.xerial:sqlite-jdbc:3.36.0.3' +} + +test { + useJUnitPlatform() +} diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/DatabaseSubkeyLookup.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/DatabaseSubkeyLookup.java new file mode 100644 index 0000000..f35d7c8 --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/DatabaseSubkeyLookup.java @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d.jdbc.sqlite; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import pgp.certificate_store.SubkeyLookup; + +public class DatabaseSubkeyLookup implements SubkeyLookup { + + private final SubkeyLookupDao dao; + + public DatabaseSubkeyLookup(SubkeyLookupDao dao) { + this.dao = dao; + } + + @Override + public Set getCertificateFingerprintsForSubkeyId(long subkeyId) throws IOException { + try { + List entries = dao.selectValues(subkeyId); + Set certificates = new HashSet<>(); + for (Entry entry : entries) { + certificates.add(entry.getCertificate()); + } + + return Collections.unmodifiableSet(certificates); + } catch (SQLException e) { + throw new IOException("Cannot query for subkey lookup entries.", e); + } + } + + @Override + public void storeCertificateSubkeyIds(String certificate, List subkeyIds) throws IOException { + try { + dao.insertValues(certificate, subkeyIds); + } catch (SQLException e) { + throw new IOException("Cannot store subkey lookup entries in database.", e); + } + } +} diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/Entry.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/Entry.java new file mode 100644 index 0000000..caff4bf --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/Entry.java @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d.jdbc.sqlite; + +/** + * Subkey-ID database entry. + */ +public class Entry { + + private final int id; + private final String certificate; + private final long subkeyId; + + public Entry(int id, long subkeyId, String certificate) { + this.id = id; + this.subkeyId = subkeyId; + this.certificate = certificate; + } + + /** + * Get the internal ID of this entry in the database. + * + * @return internal id + */ + public int getId() { + return id; + } + + /** + * Return the key-ID of the subkey. + * + * @return subkey id + */ + public long getSubkeyId() { + return subkeyId; + } + + /** + * Return the fingerprint of the certificate the subkey belongs to. + * @return fingerprint + */ + public String getCertificate() { + return certificate; + } +} diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/SqliteSubkeyLookupDaoImpl.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/SqliteSubkeyLookupDaoImpl.java new file mode 100644 index 0000000..ecc5e66 --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/SqliteSubkeyLookupDaoImpl.java @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d.jdbc.sqlite; + +import org.sqlite.SQLiteErrorCode; +import org.sqlite.SQLiteException; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +public class SqliteSubkeyLookupDaoImpl implements SubkeyLookupDao { + + private final String databaseUrl; + + private static final String CREATE_TABLE_STMT = "" + + "CREATE TABLE IF NOT EXISTS subkey_lookup (\n" + + " id integer PRIMARY KEY,\n" + // id (internal to the database) + " certificate text NOT NULL,\n" + // certificate fingerprint + " subkey_id integer NOT NULL,\n" + // subkey id + " UNIQUE(certificate, subkey_id)\n" + + ")"; + + private static final String INSERT_STMT = "" + + "INSERT INTO subkey_lookup(certificate, subkey_id) " + + "VALUES (?,?)"; + private static final String QUERY_STMT = "" + + "SELECT * FROM subkey_lookup " + + "WHERE subkey_id=?"; + + public SqliteSubkeyLookupDaoImpl(String databaseURL) throws SQLException { + this.databaseUrl = databaseURL; + try (Connection connection = getConnection(); Statement statement = connection.createStatement()) { + statement.execute(CREATE_TABLE_STMT); + } + } + + public Connection getConnection() throws SQLException { + return DriverManager.getConnection(databaseUrl); + } + + public static SqliteSubkeyLookupDaoImpl forDatabaseFile(File databaseFile) throws SQLException { + return new SqliteSubkeyLookupDaoImpl("jdbc:sqlite:" + databaseFile.getAbsolutePath()); + } + + public int insertValues(String certificate, List subkeyIds) throws SQLException { + int inserted = 0; + try (Connection connection = getConnection(); PreparedStatement statement = connection.prepareStatement(INSERT_STMT)) { + for (long subkeyId : subkeyIds) { + try { + statement.setString(1, certificate); + statement.setLong(2, subkeyId); + statement.executeUpdate(); + inserted++; + } catch (SQLiteException e) { + // throw any exception, except: + // ignore unique constraint-related exceptions if we ignoreDuplicates + if (e.getResultCode().code == SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE.code) { + // ignore duplicates + } else { + throw e; + } + } + } + } + return inserted; + } + + public List selectValues(long subkeyId) throws SQLException { + List results = new ArrayList<>(); + try (Connection connection = getConnection(); PreparedStatement statement = connection.prepareStatement(QUERY_STMT)) { + statement.setLong(1, subkeyId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + Entry entry = new Entry( + resultSet.getInt("id"), + resultSet.getLong("subkey_id"), + resultSet.getString("certificate")); + results.add(entry); + } + } + } + return results; + } +} diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/SubkeyLookupDao.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/SubkeyLookupDao.java new file mode 100644 index 0000000..350751b --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/SubkeyLookupDao.java @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d.jdbc.sqlite; + +import java.sql.SQLException; +import java.util.List; + +public interface SubkeyLookupDao { + + int insertValues(String certificate, List subkeyIds) throws SQLException; + + List selectValues(long subkeyId) throws SQLException; +} diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/package-info.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/package-info.java new file mode 100644 index 0000000..cf7f9af --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/main/java/pgp/cert_d/jdbc/sqlite/package-info.java @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Implementation of a {@link pgp.certificate_store.SubkeyLookup} mechanism using an SQLite Database. + */ +package pgp.cert_d.jdbc.sqlite; diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/test/java/pgp/cert_d/jdbc/sqlite/EntryTest.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/test/java/pgp/cert_d/jdbc/sqlite/EntryTest.java new file mode 100644 index 0000000..93236b1 --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/test/java/pgp/cert_d/jdbc/sqlite/EntryTest.java @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d.jdbc.sqlite; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class EntryTest { + + @Test + public void simpleGetterTest() { + Entry entry = new Entry(1, 123L, "eb85bb5fa33a75e15e944e63f231550c4f47e38e"); + + assertEquals(1, entry.getId()); + assertEquals(123L, entry.getSubkeyId()); + assertEquals("eb85bb5fa33a75e15e944e63f231550c4f47e38e", entry.getCertificate()); + } +} diff --git a/pgp-cert-d-java-jdbc-sqlite-lookup/src/test/java/pgp/cert_d/jdbc/sqlite/SqliteSubkeyLookupTest.java b/pgp-cert-d-java-jdbc-sqlite-lookup/src/test/java/pgp/cert_d/jdbc/sqlite/SqliteSubkeyLookupTest.java new file mode 100644 index 0000000..b71337c --- /dev/null +++ b/pgp-cert-d-java-jdbc-sqlite-lookup/src/test/java/pgp/cert_d/jdbc/sqlite/SqliteSubkeyLookupTest.java @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d.jdbc.sqlite; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class SqliteSubkeyLookupTest { + + private File databaseFile; + private DatabaseSubkeyLookup lookup; + + @BeforeEach + public void setupLookup() throws IOException, SQLException { + databaseFile = Files.createTempFile("pgp.cert.d-", "lookup.db").toFile(); + databaseFile.createNewFile(); + databaseFile.deleteOnExit(); + lookup = new DatabaseSubkeyLookup(SqliteSubkeyLookupDaoImpl.forDatabaseFile(databaseFile)); + } + + @Test + public void simpleInsertAndGet() throws IOException { + store("eb85bb5fa33a75e15e944e63f231550c4f47e38e", 123L, 234L); + store("d1a66e1a23b182c9980f788cfbfcc82a015e7330", 234L); + + assertEquals(Collections.singleton("eb85bb5fa33a75e15e944e63f231550c4f47e38e"), lookup.getCertificateFingerprintsForSubkeyId(123L)); + assertEquals( + new HashSet<>(Arrays.asList("eb85bb5fa33a75e15e944e63f231550c4f47e38e", "d1a66e1a23b182c9980f788cfbfcc82a015e7330")), + lookup.getCertificateFingerprintsForSubkeyId(234L)); + } + + @Test + public void getNonExistingSubkeyYieldsNull() throws IOException { + assertTrue(lookup.getCertificateFingerprintsForSubkeyId(6666666).isEmpty()); + } + + @Test + public void secondInstanceLookupTest() throws IOException, SQLException { + store("eb85bb5fa33a75e15e944e63f231550c4f47e38e", 1337L); + assertEquals(Collections.singleton("eb85bb5fa33a75e15e944e63f231550c4f47e38e"), lookup.getCertificateFingerprintsForSubkeyId(1337)); + + // do the lookup using a second db instance on the same file + DatabaseSubkeyLookup secondInstance = new DatabaseSubkeyLookup(SqliteSubkeyLookupDaoImpl.forDatabaseFile(databaseFile)); + assertEquals(Collections.singleton("eb85bb5fa33a75e15e944e63f231550c4f47e38e"), secondInstance.getCertificateFingerprintsForSubkeyId(1337)); + } + + @Test + public void ignoreInsertDuplicates() throws IOException { + store("d1a66e1a23b182c9980f788cfbfcc82a015e7330", 123L, 234L); + // per default we ignore duplicates + store("d1a66e1a23b182c9980f788cfbfcc82a015e7330", 123L, 512L); + } + + private void store(String cert, long... ids) throws IOException { + List idList = new ArrayList<>(); + for (long id : ids) { + idList.add(id); + } + lookup.storeCertificateSubkeyIds(cert, idList); + } +} diff --git a/pgp-cert-d-java/README.md b/pgp-cert-d-java/README.md new file mode 100644 index 0000000..cb28958 --- /dev/null +++ b/pgp-cert-d-java/README.md @@ -0,0 +1,16 @@ + + +# Shared PGP Certificate Directory for Java + +Backend-agnostic implementation of the [Shared PGP Certificate Directory Specification](https://sequoia-pgp.gitlab.io/pgp-cert-d/). +This module implements the non-OpenPGP parts of the spec, e.g. locating the directory, resolving certificate file paths, +locking the directory for writes etc. + +To get a useful implementation, a backend implementation such as `pgpainless-cert-d` is required, which needs to provide +support for reading and merging certificates. + +`pgp-cert-d-java` can be used as an implementation of `pgp-certificate-store`. \ No newline at end of file diff --git a/pgp-cert-d-java/build.gradle b/pgp-cert-d-java/build.gradle new file mode 100644 index 0000000..3b78a4d --- /dev/null +++ b/pgp-cert-d-java/build.gradle @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id 'java-library' +} + +group 'org.pgpainless' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Logging + testImplementation "ch.qos.logback:logback-classic:$logbackVersion" + + testImplementation project(":pgp-cert-d-java-jdbc-sqlite-lookup") + + api project(":pgp-certificate-store") +} + +test { + useJUnitPlatform() +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/BackendProvider.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/BackendProvider.java new file mode 100644 index 0000000..70bc93c --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/BackendProvider.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import pgp.certificate_store.CertificateReaderBackend; +import pgp.certificate_store.MergeCallback; + +public abstract class BackendProvider { + + public abstract CertificateReaderBackend provideCertificateReaderBackend(); + + public abstract MergeCallback provideDefaultMergeCallback(); + +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/BaseDirectoryProvider.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/BaseDirectoryProvider.java new file mode 100644 index 0000000..bfa561f --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/BaseDirectoryProvider.java @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.io.File; +import java.nio.file.Paths; + +public class BaseDirectoryProvider { + + public static File getDefaultBaseDir() { + // Check for environment variable + String baseDirFromEnv = System.getenv("PGP_CERT_D"); + if (baseDirFromEnv != null) { + return new File(baseDirFromEnv); + } + + // return OS-specific default dir + String osName = System.getProperty("os.name", "generic") + .toLowerCase(); + return getDefaultBaseDirForOS(osName); + } + + public static File getDefaultBaseDirForOS(String osName) { + String STORE_NAME = "pgp.cert.d"; + if (osName.contains("win")) { + // %APPDATA%\Roaming\pgp.cert.d + return Paths.get(System.getenv("APPDATA"), "Roaming", STORE_NAME).toFile(); + } + + if (osName.contains("nux")) { + // $XDG_DATA_HOME/pgp.cert.d + String xdg_data_home = System.getenv("XDG_DATA_HOME"); + if (xdg_data_home != null) { + return Paths.get(xdg_data_home, STORE_NAME).toFile(); + } + // $HOME/.local/share/pgp.cert.d + return Paths.get(System.getProperty("user.home"), ".local", "share", STORE_NAME).toFile(); + } + + if (osName.contains("mac")) { + return Paths.get(System.getenv("HOME"), "Library", "Application Support", STORE_NAME).toFile(); + } + + throw new IllegalArgumentException("Unknown OS " + osName); + } + +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/CachingSharedPGPCertificateDirectoryWrapper.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/CachingSharedPGPCertificateDirectoryWrapper.java new file mode 100644 index 0000000..820d55d --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/CachingSharedPGPCertificateDirectoryWrapper.java @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import pgp.certificate_store.exception.BadDataException; +import pgp.certificate_store.exception.BadNameException; +import pgp.certificate_store.Certificate; +import pgp.certificate_store.MergeCallback; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Caching wrapper for {@link SharedPGPCertificateDirectory} implementations. + */ +public class CachingSharedPGPCertificateDirectoryWrapper + implements SharedPGPCertificateDirectory { + + private static final Map tagMap = new HashMap<>(); + private static final Map certificateMap = new HashMap<>(); + private final SharedPGPCertificateDirectory underlyingCertificateDirectory; + + public CachingSharedPGPCertificateDirectoryWrapper(SharedPGPCertificateDirectory wrapped) { + this.underlyingCertificateDirectory = wrapped; + } + + /** + * Store the given certificate under the given identifier into the cache. + * + * @param identifier fingerprint or special name + * @param certificate certificate + */ + private void remember(String identifier, Certificate certificate) { + certificateMap.put(identifier, certificate); + try { + tagMap.put(identifier, certificate.getTag()); + } catch (IOException e) { + tagMap.put(identifier, null); + } + } + + /** + * Returns true, if the cached tag differs from the provided tag. + * + * @param identifier fingerprint or special name + * @param tag tag + * @return true if cached tag differs, false otherwise + */ + private boolean tagChanged(String identifier, String tag) { + String tack = tagMap.get(identifier); + return !tagEquals(tag, tack); + } + + /** + * Return true, if tag and tack are equal, false otherwise. + * @param tag tag + * @param tack other tag + * @return true if equal + */ + private static boolean tagEquals(String tag, String tack) { + return (tag == null && tack == null) + || tag != null && tag.equals(tack); + } + + /** + * Clear the cache. + */ + public void invalidate() { + certificateMap.clear(); + tagMap.clear(); + } + + @Override + public LockingMechanism getLock() { + return underlyingCertificateDirectory.getLock(); + } + + @Override + public Certificate getByFingerprint(String fingerprint) + throws IOException, BadNameException, BadDataException { + Certificate certificate = certificateMap.get(fingerprint); + if (certificate == null) { + certificate = underlyingCertificateDirectory.getByFingerprint(fingerprint); + if (certificate != null) { + remember(fingerprint, certificate); + } + } + + return certificate; + } + + @Override + public Certificate getBySpecialName(String specialName) + throws IOException, BadNameException, BadDataException { + Certificate certificate = certificateMap.get(specialName); + if (certificate == null) { + certificate = underlyingCertificateDirectory.getBySpecialName(specialName); + if (certificate != null) { + remember(specialName, certificate); + } + } + + return certificate; + } + + @Override + public Certificate getByFingerprintIfChanged(String fingerprint, String tag) + throws IOException, BadNameException, BadDataException { + if (tagChanged(fingerprint, tag)) { + return getByFingerprint(fingerprint); + } + return null; + } + + @Override + public Certificate getBySpecialNameIfChanged(String specialName, String tag) + throws IOException, BadNameException, BadDataException { + if (tagChanged(specialName, tag)) { + return getBySpecialName(specialName); + } + return null; + } + + @Override + public Certificate insert(InputStream data, MergeCallback merge) + throws IOException, BadDataException, InterruptedException { + Certificate certificate = underlyingCertificateDirectory.insert(data, merge); + remember(certificate.getFingerprint(), certificate); + return certificate; + } + + @Override + public Certificate tryInsert(InputStream data, MergeCallback merge) + throws IOException, BadDataException { + Certificate certificate = underlyingCertificateDirectory.tryInsert(data, merge); + if (certificate != null) { + remember(certificate.getFingerprint(), certificate); + } + return certificate; + } + + @Override + public Certificate insertWithSpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadDataException, BadNameException, InterruptedException { + Certificate certificate = underlyingCertificateDirectory.insertWithSpecialName(specialName, data, merge); + remember(specialName, certificate); + return certificate; + } + + @Override + public Certificate tryInsertWithSpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadDataException, BadNameException { + Certificate certificate = underlyingCertificateDirectory.tryInsertWithSpecialName(specialName, data, merge); + if (certificate != null) { + remember(specialName, certificate); + } + return certificate; + } + + @Override + public Iterator items() { + + Iterator iterator = underlyingCertificateDirectory.items(); + + return new Iterator() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Certificate next() { + Certificate certificate = iterator.next(); + remember(certificate.getFingerprint(), certificate); + return certificate; + } + }; + } + + @Override + public Iterator fingerprints() { + return underlyingCertificateDirectory.fingerprints(); + } + +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/FileLockingMechanism.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/FileLockingMechanism.java new file mode 100644 index 0000000..2d87c04 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/FileLockingMechanism.java @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; + +public class FileLockingMechanism implements LockingMechanism { + + private final File lockFile; + private RandomAccessFile randomAccessFile; + private FileLock fileLock; + + public FileLockingMechanism(File lockFile) { + this.lockFile = lockFile; + } + + public static FileLockingMechanism defaultDirectoryFileLock(File baseDirectory) { + return new FileLockingMechanism(new File(baseDirectory, "writelock")); + } + + @Override + public synchronized void lockDirectory() throws IOException, InterruptedException { + if (randomAccessFile != null) { + // we own the lock already. Let's wait... + this.wait(); + } + + try { + randomAccessFile = new RandomAccessFile(lockFile, "rw"); + } catch (FileNotFoundException e) { + lockFile.createNewFile(); + randomAccessFile = new RandomAccessFile(lockFile, "rw"); + } + + fileLock = randomAccessFile.getChannel().lock(); + } + + @Override + public synchronized boolean tryLockDirectory() throws IOException { + if (randomAccessFile != null) { + // We already locked the directory for another write operation. + // We fail, since we have not yet released the lock from the other operation. + return false; + } + + try { + randomAccessFile = new RandomAccessFile(lockFile, "rw"); + } catch (FileNotFoundException e) { + lockFile.createNewFile(); + randomAccessFile = new RandomAccessFile(lockFile, "rw"); + } + + try { + fileLock = randomAccessFile.getChannel().tryLock(); + if (fileLock == null) { + // try-lock failed, file is locked by another process. + randomAccessFile.close(); + randomAccessFile = null; + return false; + } + } catch (OverlappingFileLockException e) { + // Some other object is holding the lock. + randomAccessFile.close(); + randomAccessFile = null; + return false; + } + return true; + } + + @Override + public synchronized void releaseDirectory() throws IOException { + // unlock file + if (fileLock != null) { + fileLock.release(); + fileLock = null; + } + // close file + if (randomAccessFile != null) { + randomAccessFile.close(); + randomAccessFile = null; + } + // delete file + if (lockFile.exists()) { + lockFile.delete(); + } + // notify waiters + this.notify(); + } +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/FilenameResolver.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/FilenameResolver.java new file mode 100644 index 0000000..a753fd3 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/FilenameResolver.java @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import pgp.certificate_store.exception.BadNameException; + +import java.io.File; +import java.util.regex.Pattern; + +public class FilenameResolver { + + private final File baseDirectory; + private final Pattern openPgpV4FingerprintPattern = Pattern.compile("^[a-f0-9]{40}$"); + + public FilenameResolver(File baseDirectory) { + this.baseDirectory = baseDirectory; + } + + public File getBaseDirectory() { + return baseDirectory; + } + + /** + * Calculate the file location for the certificate addressed by the given + * lowercase hexadecimal OpenPGP fingerprint. + * + * @param fingerprint fingerprint + * @return absolute certificate file location + * @throws BadNameException + */ + public File getCertFileByFingerprint(String fingerprint) throws BadNameException { + if (!isFingerprint(fingerprint)) { + throw new BadNameException(); + } + + // is fingerprint + File subdirectory = new File(getBaseDirectory(), fingerprint.substring(0, 2)); + File file = new File(subdirectory, fingerprint.substring(2)); + return file; + } + + public File getCertFileBySpecialName(String specialName) throws BadNameException { + if (!isSpecialName(specialName)) { + throw new BadNameException(); + } + + return new File(getBaseDirectory(), specialName); + } + + private boolean isFingerprint(String fingerprint) { + return openPgpV4FingerprintPattern.matcher(fingerprint).matches(); + } + + private boolean isSpecialName(String specialName) { + return SpecialNames.lookupSpecialName(specialName) != null; + } + +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/InMemorySubkeyLookup.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/InMemorySubkeyLookup.java new file mode 100644 index 0000000..1cd7862 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/InMemorySubkeyLookup.java @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import pgp.certificate_store.SubkeyLookup; + +public class InMemorySubkeyLookup implements SubkeyLookup { + + private static final Map> subkeyMap = new HashMap<>(); + + @Override + public Set getCertificateFingerprintsForSubkeyId(long subkeyId) { + Set identifiers = subkeyMap.get(subkeyId); + if (identifiers == null) { + return Collections.emptySet(); + } + return Collections.unmodifiableSet(identifiers); + } + + @Override + public void storeCertificateSubkeyIds(String certificate, List subkeyIds) { + for (long subkeyId : subkeyIds) { + Set certificates = subkeyMap.get(subkeyId); + if (certificates == null) { + certificates = new HashSet<>(); + subkeyMap.put(subkeyId, certificates); + } + certificates.add(certificate); + } + } + + public void clear() { + subkeyMap.clear(); + } +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/LockingMechanism.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/LockingMechanism.java new file mode 100644 index 0000000..520a857 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/LockingMechanism.java @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.io.IOException; + +public interface LockingMechanism { + + /** + * Lock the store for writes. + * Readers can continue to use the store and will always see consistent certs. + */ + void lockDirectory() throws IOException, InterruptedException; + + /** + * Try top lock the store for writes. + * Return false without locking the store in case the store was already locked. + * + * @return true if locking succeeded, false otherwise + */ + boolean tryLockDirectory() throws IOException; + + /** + * Release the directory write-lock acquired via {@link #lockDirectory()}. + */ + void releaseDirectory() throws IOException; + +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/SharedPGPCertificateDirectory.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/SharedPGPCertificateDirectory.java new file mode 100644 index 0000000..9c80965 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/SharedPGPCertificateDirectory.java @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; + +import pgp.certificate_store.exception.BadDataException; +import pgp.certificate_store.exception.BadNameException; +import pgp.certificate_store.Certificate; +import pgp.certificate_store.MergeCallback; + +public interface SharedPGPCertificateDirectory { + + LockingMechanism getLock(); + + Certificate getByFingerprint(String fingerprint) + throws IOException, BadNameException, BadDataException; + + Certificate getBySpecialName(String specialName) + throws IOException, BadNameException, BadDataException; + + Certificate getByFingerprintIfChanged(String fingerprint, String tag) + throws IOException, BadNameException, BadDataException; + + Certificate getBySpecialNameIfChanged(String specialName, String tag) + throws IOException, BadNameException, BadDataException; + + Certificate insert(InputStream data, MergeCallback merge) + throws IOException, BadDataException, InterruptedException; + + Certificate tryInsert(InputStream data, MergeCallback merge) + throws IOException, BadDataException; + + Certificate insertWithSpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadDataException, BadNameException, InterruptedException; + + Certificate tryInsertWithSpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadDataException, BadNameException; + + Iterator items(); + + Iterator fingerprints(); +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/SharedPGPCertificateDirectoryImpl.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/SharedPGPCertificateDirectoryImpl.java new file mode 100644 index 0000000..7b0a917 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/SharedPGPCertificateDirectoryImpl.java @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileFilter; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import pgp.certificate_store.exception.BadDataException; +import pgp.certificate_store.exception.BadNameException; +import pgp.certificate_store.exception.NotAStoreException; +import pgp.certificate_store.Certificate; +import pgp.certificate_store.CertificateReaderBackend; +import pgp.certificate_store.MergeCallback; + +public class SharedPGPCertificateDirectoryImpl implements SharedPGPCertificateDirectory { + + private final FilenameResolver resolver; + private final LockingMechanism writeLock; + private final CertificateReaderBackend certificateReaderBackend; + + public SharedPGPCertificateDirectoryImpl(BackendProvider backendProvider) + throws NotAStoreException { + this(backendProvider.provideCertificateReaderBackend()); + } + + public SharedPGPCertificateDirectoryImpl(CertificateReaderBackend certificateReaderBackend) + throws NotAStoreException { + this( + BaseDirectoryProvider.getDefaultBaseDir(), + certificateReaderBackend); + } + + public SharedPGPCertificateDirectoryImpl(File baseDirectory, CertificateReaderBackend certificateReaderBackend) + throws NotAStoreException { + this( + certificateReaderBackend, + new FilenameResolver(baseDirectory), + FileLockingMechanism.defaultDirectoryFileLock(baseDirectory)); + } + + public SharedPGPCertificateDirectoryImpl( + CertificateReaderBackend certificateReaderBackend, + FilenameResolver filenameResolver, + LockingMechanism writeLock) + throws NotAStoreException { + this.certificateReaderBackend = certificateReaderBackend; + this.resolver = filenameResolver; + this.writeLock = writeLock; + + File baseDirectory = resolver.getBaseDirectory(); + if (!baseDirectory.exists()) { + if (!baseDirectory.mkdirs()) { + throw new NotAStoreException("Cannot create base directory '" + resolver.getBaseDirectory().getAbsolutePath() + "'"); + } + } else { + if (baseDirectory.isFile()) { + throw new NotAStoreException("Base directory '" + resolver.getBaseDirectory().getAbsolutePath() + "' appears to be a file."); + } + } + } + + @Override + public LockingMechanism getLock() { + return writeLock; + } + + @Override + public Certificate getByFingerprint(String fingerprint) + throws IOException, BadNameException, BadDataException { + File certFile = resolver.getCertFileByFingerprint(fingerprint); + if (!certFile.exists()) { + return null; + } + + FileInputStream fileIn = new FileInputStream(certFile); + BufferedInputStream bufferedIn = new BufferedInputStream(fileIn); + Certificate certificate = certificateReaderBackend.readCertificate(bufferedIn); + + if (!certificate.getFingerprint().equals(fingerprint)) { + // TODO: Figure out more suitable exception + throw new BadDataException(); + } + + return certificate; + } + + @Override + public Certificate getBySpecialName(String specialName) + throws IOException, BadNameException { + File certFile = resolver.getCertFileBySpecialName(specialName); + if (!certFile.exists()) { + return null; + } + + FileInputStream fileIn = new FileInputStream(certFile); + BufferedInputStream bufferedIn = new BufferedInputStream(fileIn); + Certificate certificate = certificateReaderBackend.readCertificate(bufferedIn); + + return certificate; + } + + @Override + public Certificate getByFingerprintIfChanged(String fingerprint, String tag) + throws IOException, BadNameException, BadDataException { + Certificate certificate = getByFingerprint(fingerprint); + if (certificate.getTag().equals(tag)) { + return null; + } + return certificate; + } + + @Override + public Certificate getBySpecialNameIfChanged(String specialName, String tag) + throws IOException, BadNameException { + Certificate certificate = getBySpecialName(specialName); + if (certificate.getTag().equals(tag)) { + return null; + } + return certificate; + } + + @Override + public Certificate insert(InputStream data, MergeCallback merge) + throws IOException, BadDataException, InterruptedException { + writeLock.lockDirectory(); + + Certificate certificate = _insert(data, merge); + + writeLock.releaseDirectory(); + return certificate; + } + + @Override + public Certificate tryInsert(InputStream data, MergeCallback merge) + throws IOException, BadDataException { + if (!writeLock.tryLockDirectory()) { + return null; + } + + Certificate certificate = _insert(data, merge); + + writeLock.releaseDirectory(); + return certificate; + } + + private Certificate _insert(InputStream data, MergeCallback merge) + throws IOException, BadDataException { + Certificate newCertificate = certificateReaderBackend.readCertificate(data); + Certificate existingCertificate; + File certFile; + try { + existingCertificate = getByFingerprint(newCertificate.getFingerprint()); + certFile = resolver.getCertFileByFingerprint(newCertificate.getFingerprint()); + } catch (BadNameException e) { + throw new BadDataException(); + } + + if (existingCertificate != null && !existingCertificate.getTag().equals(newCertificate.getTag())) { + newCertificate = merge.merge(newCertificate, existingCertificate); + } + + writeCertificate(newCertificate, certFile); + + return newCertificate; + } + + private void writeCertificate(Certificate certificate, File certFile) + throws IOException { + certFile.getParentFile().mkdirs(); + if (!certFile.exists() && !certFile.createNewFile()) { + throw new IOException("Could not create cert file " + certFile.getAbsolutePath()); + } + + InputStream certIn = certificate.getInputStream(); + FileOutputStream fileOut = new FileOutputStream(certFile); + + byte[] buffer = new byte[4096]; + int read; + while ((read = certIn.read(buffer)) != -1) { + fileOut.write(buffer, 0, read); + } + + certIn.close(); + fileOut.close(); + } + + @Override + public Certificate insertWithSpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadNameException, BadDataException, InterruptedException { + writeLock.lockDirectory(); + + Certificate certificate = _insertSpecial(specialName, data, merge); + + writeLock.releaseDirectory(); + return certificate; + } + + @Override + public Certificate tryInsertWithSpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadNameException, BadDataException { + if (!writeLock.tryLockDirectory()) { + return null; + } + + Certificate certificate = _insertSpecial(specialName, data, merge); + + writeLock.releaseDirectory(); + return certificate; + } + + private Certificate _insertSpecial(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadNameException, BadDataException { + Certificate newCertificate = certificateReaderBackend.readCertificate(data); + Certificate existingCertificate = getBySpecialName(specialName); + File certFile = resolver.getCertFileBySpecialName(specialName); + + if (existingCertificate != null && !existingCertificate.getTag().equals(newCertificate.getTag())) { + newCertificate = merge.merge(newCertificate, existingCertificate); + } + + writeCertificate(newCertificate, certFile); + + return newCertificate; + } + + @Override + public Iterator items() { + return new Iterator() { + + private final List> certificateQueue = Collections.synchronizedList(new ArrayList<>()); + + // Constructor... wtf. + { + File[] subdirectories = resolver.getBaseDirectory().listFiles(new FileFilter() { + @Override + public boolean accept(File file) { + return file.isDirectory() && file.getName().matches("^[a-f0-9]{2}$"); + } + }); + + for (File subdirectory : subdirectories) { + File[] files = subdirectory.listFiles(new FileFilter() { + @Override + public boolean accept(File file) { + return file.isFile() && file.getName().matches("^[a-f0-9]{38}$"); + } + }); + + for (File certFile : files) { + certificateQueue.add(new Lazy() { + @Override + Certificate get() throws BadDataException { + try { + Certificate certificate = certificateReaderBackend.readCertificate(new FileInputStream(certFile)); + if (!(subdirectory.getName() + certFile.getName()).equals(certificate.getFingerprint())) { + throw new BadDataException(); + } + return certificate; + } catch (IOException e) { + throw new AssertionError("File got deleted."); + } + } + }); + } + } + } + + @Override + public boolean hasNext() { + return !certificateQueue.isEmpty(); + } + + @Override + public Certificate next() { + try { + return certificateQueue.remove(0).get(); + } catch (BadDataException e) { + throw new AssertionError("Could not retrieve item: " + e.getMessage()); + } + } + }; + } + + private abstract static class Lazy { + abstract E get() throws BadDataException; + } + + @Override + public Iterator fingerprints() { + Iterator certificates = items(); + return new Iterator() { + @Override + public boolean hasNext() { + return certificates.hasNext(); + } + + @Override + public String next() { + return certificates.next().getFingerprint(); + } + }; + } +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/SpecialNames.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/SpecialNames.java new file mode 100644 index 0000000..682d834 --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/SpecialNames.java @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import java.util.HashMap; +import java.util.Map; + +public class SpecialNames { + + private static final Map SPECIAL_NAMES = new HashMap<>(); + + static { + SPECIAL_NAMES.put("TRUST-ROOT", "trust-root"); // TODO: Remove + SPECIAL_NAMES.put("trust-root", "trust-root"); + } + + public static String lookupSpecialName(String specialName) { + return SPECIAL_NAMES.get(specialName); + } +} diff --git a/pgp-cert-d-java/src/main/java/pgp/cert_d/package-info.java b/pgp-cert-d-java/src/main/java/pgp/cert_d/package-info.java new file mode 100644 index 0000000..030e7fd --- /dev/null +++ b/pgp-cert-d-java/src/main/java/pgp/cert_d/package-info.java @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * An implementation of the Shared PGP Certificate Directory for java. + * + * @see Shared PGP Certificate Directory + */ +package pgp.cert_d; diff --git a/pgp-cert-d-java/src/test/java/pgp/cert_d/BaseDirectoryProviderTest.java b/pgp-cert-d-java/src/test/java/pgp/cert_d/BaseDirectoryProviderTest.java new file mode 100644 index 0000000..357ba3f --- /dev/null +++ b/pgp-cert-d-java/src/test/java/pgp/cert_d/BaseDirectoryProviderTest.java @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +public class BaseDirectoryProviderTest { + + @Test + public void testGetDefaultBaseDir_Linux() { + assumeTrue(System.getProperty("os.name").equalsIgnoreCase("linux")); + File baseDir = BaseDirectoryProvider.getDefaultBaseDirForOS("linux"); + assertTrue(baseDir.getAbsolutePath().endsWith("/.local/share/pgp.cert.d")); + } + + @Test + public void testGetDefaultBaseDir_Windows() { + assumeTrue(System.getProperty("os.name").toLowerCase().contains("win")); + File baseDir = BaseDirectoryProvider.getDefaultBaseDirForOS("Windows"); + assertTrue(baseDir.getAbsolutePath().endsWith("\\Roaming\\pgp.cert.d")); + } + + @Test + public void testGetDefaultBaseDir_Mac() { + assumeTrue(System.getProperty("os.name").toLowerCase().contains("mac")); + File baseDir = BaseDirectoryProvider.getDefaultBaseDirForOS("Mac"); + assertTrue(baseDir.getAbsolutePath().endsWith("/Library/Application Support/pgp.cert.d")); + } + + @Test + public void testGetDefaultBaseDirNotNull() { + File baseDir = BaseDirectoryProvider.getDefaultBaseDir(); + assertNotNull(baseDir); + } +} diff --git a/pgp-cert-d-java/src/test/java/pgp/cert_d/FilenameResolverTest.java b/pgp-cert-d-java/src/test/java/pgp/cert_d/FilenameResolverTest.java new file mode 100644 index 0000000..1d534a4 --- /dev/null +++ b/pgp-cert-d-java/src/test/java/pgp/cert_d/FilenameResolverTest.java @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import pgp.certificate_store.exception.BadNameException; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class FilenameResolverTest { + + private File baseDir; + private FilenameResolver resolver; + + @BeforeEach + public void setup() throws IOException { + baseDir = Files.createTempDirectory("filenameresolver").toFile(); + baseDir.deleteOnExit(); + resolver = new FilenameResolver(baseDir); + } + + @Test + public void testGetFileForFingerprint1() throws BadNameException { + String fingerprint = "d1a66e1a23b182c9980f788cfbfcc82a015e7330"; + + File subDir = new File(baseDir, "d1"); + File expected = new File(subDir, "a66e1a23b182c9980f788cfbfcc82a015e7330"); + + assertEquals(expected.getAbsolutePath(), resolver.getCertFileByFingerprint(fingerprint).getAbsolutePath()); + } + + @Test + public void testGetFileForFingerprint2() throws BadNameException { + String fingerprint = "eb85bb5fa33a75e15e944e63f231550c4f47e38e"; + + File subDir = new File(baseDir, "eb"); + File expected = new File(subDir, "85bb5fa33a75e15e944e63f231550c4f47e38e"); + + assertEquals(expected.getAbsolutePath(), resolver.getCertFileByFingerprint(fingerprint).getAbsolutePath()); + } + + @Test + public void testGetFileForInvalidNonHexFingerprint() { + String invalidFingerprint = "thisisnothexadecimalthisisnothexadecimal"; + assertThrows(BadNameException.class, () -> resolver.getCertFileByFingerprint(invalidFingerprint)); + } + + @Test + public void testGetFileForInvalidWrongLengthFingerprint() { + String invalidFingerprint = "d1a66e1a23b182c9980f788cfbfcc82a015e73301234"; + assertThrows(BadNameException.class, () -> resolver.getCertFileByFingerprint(invalidFingerprint)); + } + + @Test + public void testGetFileForNullFingerprint() { + assertThrows(NullPointerException.class, () -> resolver.getCertFileByFingerprint(null)); + } + + @Test + public void testGetFileForSpecialName() throws BadNameException { + String specialName = "trust-root"; + File expected = new File(baseDir, "trust-root"); + + assertEquals(expected, resolver.getCertFileBySpecialName(specialName)); + } + + @Test + public void testGetFileForInvalidSpecialName() { + String invalidSpecialName = "invalid"; + assertThrows(BadNameException.class, () -> resolver.getCertFileBySpecialName(invalidSpecialName)); + } +} diff --git a/pgp-cert-d-java/src/test/java/pgp/cert_d/SpecialNamesTest.java b/pgp-cert-d-java/src/test/java/pgp/cert_d/SpecialNamesTest.java new file mode 100644 index 0000000..83a2d5d --- /dev/null +++ b/pgp-cert-d-java/src/test/java/pgp/cert_d/SpecialNamesTest.java @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class SpecialNamesTest { + + @Test + public void bothTrustRootNotationsAreRecognized() { + assertEquals("trust-root", SpecialNames.lookupSpecialName("trust-root")); + assertEquals("trust-root", SpecialNames.lookupSpecialName("TRUST-ROOT")); + } + + @Test + public void testInvalidSpecialNameReturnsNull() { + assertNull(SpecialNames.lookupSpecialName("invalid")); + assertNull(SpecialNames.lookupSpecialName("trust root")); + assertNull(SpecialNames.lookupSpecialName("writelock")); + } +} diff --git a/pgp-cert-d-java/src/test/java/pgp/cert_d/SubkeyLookupTest.java b/pgp-cert-d-java/src/test/java/pgp/cert_d/SubkeyLookupTest.java new file mode 100644 index 0000000..03ad9c8 --- /dev/null +++ b/pgp-cert-d-java/src/test/java/pgp/cert_d/SubkeyLookupTest.java @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.cert_d; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import pgp.cert_d.jdbc.sqlite.DatabaseSubkeyLookup; +import pgp.cert_d.jdbc.sqlite.SqliteSubkeyLookupDaoImpl; +import pgp.certificate_store.SubkeyLookup; + +public class SubkeyLookupTest { + + private static final List testSubjects = new ArrayList<>(); + + @BeforeAll + public static void setupLookupTestSubjects() throws IOException, SQLException { + InMemorySubkeyLookup inMemorySubkeyLookup = new InMemorySubkeyLookup(); + testSubjects.add(inMemorySubkeyLookup); + + File sqliteDatabase = Files.createTempFile("subkeyLookupTest", ".db").toFile(); + sqliteDatabase.createNewFile(); + sqliteDatabase.deleteOnExit(); + DatabaseSubkeyLookup sqliteSubkeyLookup = new DatabaseSubkeyLookup(SqliteSubkeyLookupDaoImpl.forDatabaseFile(sqliteDatabase)); + testSubjects.add(sqliteSubkeyLookup); + } + + @AfterAll + public static void tearDownLookupTestSubjects() { + ((InMemorySubkeyLookup) testSubjects.get(0)).clear(); + } + + private static Stream provideSubkeyLookupsForTest() { + return testSubjects.stream(); + } + + @ParameterizedTest + @MethodSource("provideSubkeyLookupsForTest") + public void testInsertGet(SubkeyLookup subject) throws IOException { + // Initially all null + + assertTrue(subject.getCertificateFingerprintsForSubkeyId(123).isEmpty()); + assertTrue(subject.getCertificateFingerprintsForSubkeyId(1337).isEmpty()); + assertTrue(subject.getCertificateFingerprintsForSubkeyId(420).isEmpty()); + + // Store one val, others still null + + subject.storeCertificateSubkeyIds("d1a66e1a23b182c9980f788cfbfcc82a015e7330", Collections.singletonList(123L)); + + assertEquals(Collections.singleton("d1a66e1a23b182c9980f788cfbfcc82a015e7330"), subject.getCertificateFingerprintsForSubkeyId(123)); + assertTrue(subject.getCertificateFingerprintsForSubkeyId(1337).isEmpty()); + assertTrue(subject.getCertificateFingerprintsForSubkeyId(420).isEmpty()); + + // Store other val, first stays intact + + subject.storeCertificateSubkeyIds("d1a66e1a23b182c9980f788cfbfcc82a015e7330", Collections.singletonList(1337L)); + subject.storeCertificateSubkeyIds("d1a66e1a23b182c9980f788cfbfcc82a015e7330", Collections.singletonList(420L)); + + assertEquals(Collections.singleton("d1a66e1a23b182c9980f788cfbfcc82a015e7330"), subject.getCertificateFingerprintsForSubkeyId(123)); + assertEquals(Collections.singleton("d1a66e1a23b182c9980f788cfbfcc82a015e7330"), subject.getCertificateFingerprintsForSubkeyId(1337)); + assertEquals(Collections.singleton("d1a66e1a23b182c9980f788cfbfcc82a015e7330"), subject.getCertificateFingerprintsForSubkeyId(420)); + + // add additional entry for subkey + + subject.storeCertificateSubkeyIds("eb85bb5fa33a75e15e944e63f231550c4f47e38e", Collections.singletonList(123L)); + + assertEquals( + new HashSet<>(Arrays.asList("eb85bb5fa33a75e15e944e63f231550c4f47e38e", "d1a66e1a23b182c9980f788cfbfcc82a015e7330")), + subject.getCertificateFingerprintsForSubkeyId(123)); + } +} diff --git a/pgp-certificate-store/README.md b/pgp-certificate-store/README.md new file mode 100644 index 0000000..bdbecdc --- /dev/null +++ b/pgp-certificate-store/README.md @@ -0,0 +1,10 @@ + + +# PGP Certificate Store Definitions + +This module contains API definitions for a certificate store for PGPainless. +A certificate store is used to store public key certificates only. \ No newline at end of file diff --git a/pgp-certificate-store/build.gradle b/pgp-certificate-store/build.gradle new file mode 100644 index 0000000..599407c --- /dev/null +++ b/pgp-certificate-store/build.gradle @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id 'java-library' +} + +group 'org.pgpainless' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Logging + api "org.slf4j:slf4j-api:$slf4jVersion" + testImplementation "ch.qos.logback:logback-classic:$logbackVersion" +} + +test { + useJUnitPlatform() +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/AbstractCertificateStore.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/AbstractCertificateStore.java new file mode 100644 index 0000000..3bd394e --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/AbstractCertificateStore.java @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import pgp.certificate_store.exception.BadDataException; +import pgp.certificate_store.exception.BadNameException; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public abstract class AbstractCertificateStore implements CertificateStore { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractCertificateStore.class); + + public Set getCertificatesBySubkeyId(long subkeyId) + throws IOException { + Set identifiers = getCertificateFingerprintsForSubkeyId(subkeyId); + if (identifiers.isEmpty()) { + return Collections.emptySet(); + } + + Set certificates = new HashSet<>(); + for (String identifier : identifiers) { + try { + certificates.add(getCertificate(identifier)); + } catch (BadNameException | BadDataException e) { + LOGGER.warn("Could not read certificate.", e); + } + } + + return certificates; + } +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/Certificate.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/Certificate.java new file mode 100644 index 0000000..b7aca12 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/Certificate.java @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Set; + +public abstract class Certificate { + /** + * Return the fingerprint of the certificate as 40 lowercase hex characters. + * TODO: Allow OpenPGP V5 fingerprints + * + * @return fingerprint + */ + public abstract String getFingerprint(); + + /** + * Return an {@link InputStream} of the binary representation of the certificate. + * + * @return input stream + */ + public abstract InputStream getInputStream() throws IOException; + + /** + * Return a tag of the certificate. + * The tag is a checksum calculated over the binary representation of the certificate. + * + * @return tag + */ + public abstract String getTag() throws IOException; + + public abstract Set getSubkeyIds() throws IOException; +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateDirectory.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateDirectory.java new file mode 100644 index 0000000..29f5998 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateDirectory.java @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import pgp.certificate_store.exception.BadDataException; +import pgp.certificate_store.exception.BadNameException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; + +/** + * Certificate storage definition. + * This interface defines methods to insert and retrieve {@link Certificate Certificates} to and from a store. + * + * {@link Certificate Certificates} are hereby identified by identifiers. An identifier can either be a fingerprint + * or a special name. Special names are implementation-defined identifiers for certificates. + * + * Fingerprints are expected to be hexadecimal lowercase character sequences. + */ +public interface CertificateDirectory { + + /** + * Return the certificate that matches the given identifier. + * If no matching certificate can be found, return null. + * + * @param identifier identifier for a certificate. + * @return certificate or null + * + * @throws IOException in case of an IO-error + */ + Certificate getCertificate(String identifier) + throws IOException, BadNameException, BadDataException; + + /** + * Return the certificate that matches the given identifier, but only iff it changed since the last invocation. + * To compare the certificate against its last returned result, the given tag is used. + * If the tag of the currently found certificate matches the given argument, return null. + * + * @param identifier identifier for a certificate + * @param tag tag to compare freshness + * @return changed certificate or null + * + * @throws IOException in case of an IO-error + */ + Certificate getCertificateIfChanged(String identifier, String tag) + throws IOException, BadNameException, BadDataException; + + /** + * Insert a certificate into the store. + * If an instance of the certificate is already present in the store, the given {@link MergeCallback} will be + * used to merge both the existing and the new instance of the {@link Certificate}. The resulting merged certificate + * will be stored in the store and returned. + * + * This method will block until a write-lock on the store can be acquired. If you cannot afford blocking, + * consider to use {@link #tryInsertCertificate(InputStream, MergeCallback)} instead. + * + * @param data input stream containing the new certificate instance + * @param merge callback for merging with an existing certificate instance + * @return merged certificate + * + * @throws IOException in case of an IO-error + * @throws InterruptedException in case the inserting thread gets interrupted + */ + Certificate insertCertificate(InputStream data, MergeCallback merge) + throws IOException, InterruptedException, BadDataException; + + /** + * Insert a certificate into the store. + * If an instance of the certificate is already present in the store, the given {@link MergeCallback} will be + * used to merge both the existing and the new instance of the {@link Certificate}. The resulting merged certificate + * will be stored in the store and returned. + * + * This method will not block. Instead, if the store is already write-locked, this method will simply return null + * without any writing. + * However, if the write-lock is available, this method will acquire the lock, write to the store, release the lock + * and return the written certificate. + * + * @param data input stream containing the new certificate instance + * @param merge callback for merging with an existing certificate instance + * @return merged certificate or null if the store cannot be locked + * + * @throws IOException in case of an IO-error + */ + Certificate tryInsertCertificate(InputStream data, MergeCallback merge) + throws IOException, BadDataException; + + /** + * Insert a certificate into the store. + * The certificate will be stored under the given special name instead of its fingerprint. + * + * If an instance of the certificate is already present under the special name in the store, the given {@link MergeCallback} will be + * used to merge both the existing and the new instance of the {@link Certificate}. The resulting merged certificate + * will be stored in the store and returned. + * + * This method will block until a write-lock on the store can be acquired. If you cannot afford blocking, + * consider to use {@link #tryInsertCertificateBySpecialName(String, InputStream, MergeCallback)} instead. + * + * @param data input stream containing the new certificate instance + * @param merge callback for merging with an existing certificate instance + * @return merged certificate or null if the store cannot be locked + * + * @throws IOException in case of an IO-error + */ + Certificate insertCertificateBySpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, InterruptedException, BadDataException, BadNameException; + + /** + * Insert a certificate into the store. + * The certificate will be stored under the given special name instead of its fingerprint. + * + * If an instance of the certificate is already present under the special name in the store, the given {@link MergeCallback} will be + * used to merge both the existing and the new instance of the {@link Certificate}. The resulting merged certificate + * will be stored in the store and returned. + * + * This method will not block. Instead, if the store is already write-locked, this method will simply return null + * without any writing. + * However, if the write-lock is available, this method will acquire the lock, write to the store, release the lock + * and return the written certificate. + * + * @param data input stream containing the new certificate instance + * @param merge callback for merging with an existing certificate instance + * @return merged certificate or null if the store cannot be locked + * + * @throws IOException in case of an IO-error + */ + Certificate tryInsertCertificateBySpecialName(String specialName, InputStream data, MergeCallback merge) + throws IOException, BadDataException, BadNameException; + + /** + * Return an {@link Iterator} containing all certificates in the store. + * The iterator will contain both certificates addressed by special names and by fingerprints. + * + * @return certificates + */ + Iterator getCertificates(); + + /** + * Return an {@link Iterator} containing all certificate fingerprints from the store. + * Note that this only includes the fingerprints of certificate primary keys, not those of subkeys. + * + * @return fingerprints + */ + Iterator getFingerprints(); +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateReaderBackend.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateReaderBackend.java new file mode 100644 index 0000000..c16b111 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateReaderBackend.java @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Interface definition for a class that can read {@link Certificate Certificates} from binary + * {@link InputStream InputStreams}. + */ +public interface CertificateReaderBackend { + + /** + * Read a {@link Certificate} from the given {@link InputStream}. + * + * @param inputStream input stream containing the binary representation of the certificate. + * @return certificate object + * + * @throws IOException in case of an IO error + */ + Certificate readCertificate(InputStream inputStream) throws IOException; + +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateStore.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateStore.java new file mode 100644 index 0000000..a8325ee --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/CertificateStore.java @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +public interface CertificateStore extends CertificateDirectory, SubkeyLookup { + +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/MergeCallback.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/MergeCallback.java new file mode 100644 index 0000000..a7cee53 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/MergeCallback.java @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import java.io.IOException; + +/** + * Merge a given certificate (update) with an existing certificate. + */ +public interface MergeCallback { + + /** + * Merge the given certificate data with the existing certificate and return the result. + * + * If no existing certificate is found (i.e. existing is null), this method returns the unmodified data. + * + * @param data certificate + * @param existing optional already existing copy of the certificate + * @return merged certificate + */ + Certificate merge(Certificate data, Certificate existing) throws IOException; + +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/SubkeyLookup.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/SubkeyLookup.java new file mode 100644 index 0000000..73e396b --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/SubkeyLookup.java @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +public interface SubkeyLookup { + + /** + * Lookup the fingerprint of the certificate that contains the given subkey. + * If no record is found, return null. + * + * @param subkeyId subkey id + * @return fingerprint of the certificate + */ + Set getCertificateFingerprintsForSubkeyId(long subkeyId) throws IOException; + + /** + * Record, which certificate the subkey-ids in the list belong to. + * This method does not change the affiliation of subkey-ids not contained in the provided list. + * + * @param certificate certificate fingerprint + * @param subkeyIds subkey ids + * @throws IOException in case of an IO error + */ + void storeCertificateSubkeyIds(String certificate, List subkeyIds) throws IOException; +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/BadDataException.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/BadDataException.java new file mode 100644 index 0000000..3bb7019 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/BadDataException.java @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store.exception; + +/** + * The data was not a valid OpenPGP cert or key in binary format. + */ +public class BadDataException extends Exception { + +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/BadNameException.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/BadNameException.java new file mode 100644 index 0000000..957126e --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/BadNameException.java @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store.exception; + +/** + * Provided name was neither a valid fingerprint, nor a known special name. + */ +public class BadNameException extends Exception { + + public BadNameException() { + super(); + } + + public BadNameException(String message) { + super(message); + } +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/NotAStoreException.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/NotAStoreException.java new file mode 100644 index 0000000..a19aa9c --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/NotAStoreException.java @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store.exception; + +/** + * The base dir cannot possibly contain a store. + */ +public class NotAStoreException extends Exception { + + public NotAStoreException() { + super(); + } + + public NotAStoreException(String message) { + super(message); + } +} diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/package-info.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/package-info.java new file mode 100644 index 0000000..c06ce06 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/exception/package-info.java @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Exceptions defined by the Shared PGP Certificate Directory. + * + * @see Failure Modes + */ +package pgp.certificate_store.exception; diff --git a/pgp-certificate-store/src/main/java/pgp/certificate_store/package-info.java b/pgp-certificate-store/src/main/java/pgp/certificate_store/package-info.java new file mode 100644 index 0000000..39164d4 --- /dev/null +++ b/pgp-certificate-store/src/main/java/pgp/certificate_store/package-info.java @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Abstract definitions of an OpenPGP certificate store. + */ +package pgp.certificate_store; diff --git a/pgp-certificate-store/src/test/java/pgp/certificate_store/DummyTest.java b/pgp-certificate-store/src/test/java/pgp/certificate_store/DummyTest.java new file mode 100644 index 0000000..e766c29 --- /dev/null +++ b/pgp-certificate-store/src/test/java/pgp/certificate_store/DummyTest.java @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package pgp.certificate_store; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DummyTest { + @Test + public void test() { + assertTrue(true); + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9ff5a13 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: CC0-1.0 + +rootProject.name = 'cert-d-java' + +include 'pgpainless-core', + 'pgpainless-sop', + 'pgpainless-cli' + diff --git a/version.gradle b/version.gradle new file mode 100644 index 0000000..5ec456c --- /dev/null +++ b/version.gradle @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: CC0-1.0 + +allprojects { + ext { + shortVersion = '1.1.2' + isSnapshot = true + minAndroidSdk = 10 + javaSourceCompatibility = 1.8 + bouncyCastleVersion = '1.70' + } +}