diff --git a/scm-core/src/main/java/sonia/scm/security/GPG.java b/scm-core/src/main/java/sonia/scm/security/GPG.java index 2fa773c906..2f75b8d5dc 100644 --- a/scm-core/src/main/java/sonia/scm/security/GPG.java +++ b/scm-core/src/main/java/sonia/scm/security/GPG.java @@ -35,6 +35,7 @@ public interface GPG { /** * Returns the id of the key from the given signature. + * * @param signature signature * @return public key id */ @@ -42,6 +43,7 @@ public interface GPG { /** * Returns the public key with the given id or an empty optional. + * * @param id id of public * @return public key or empty optional */ @@ -49,6 +51,7 @@ public interface GPG { /** * Returns all public keys assigned to the given username + * * @param username username of the public key owner * @return collection of public keys */ diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DummyGPG.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java similarity index 63% rename from scm-webapp/src/main/java/sonia/scm/security/gpg/DummyGPG.java rename to scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java index 79c6e516bb..792610e30c 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/DummyGPG.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java @@ -24,30 +24,57 @@ package sonia.scm.security.gpg; +import org.bouncycastle.openpgp.PGPException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import sonia.scm.security.GPG; import sonia.scm.security.PrivateKey; import sonia.scm.security.PublicKey; +import javax.inject.Inject; +import java.io.IOException; import java.util.Collections; +import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; -/** - * Dummy implementation of {@link GPG} should be replaced soon. - */ -public class DummyGPG implements GPG { +public class DefaultGPG implements GPG { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultGPG.class); + private final PublicKeyStore store; + + @Inject + public DefaultGPG(PublicKeyStore store) { + this.store = store; + } @Override public String findPublicKeyId(byte[] signature) { - return "unknown"; + try { + return Keys.resolveIdFromKey(new String(signature)); + } catch (PGPException | IOException e) { + LOG.error("Could not find public key id in signature"); + } + return ""; } @Override public Optional findPublicKey(String id) { - return Optional.empty(); + Optional key = store.findById(id); + return key.map(rawGpgKey -> new GpgKey(id, rawGpgKey.getOwner())); } @Override public Iterable findPublicKeysByUsername(String username) { + List keys = store.findByUsername(username); + + if (!keys.isEmpty()) { + return keys + .stream() + .map(rawGpgKey -> new GpgKey(rawGpgKey.getId(), rawGpgKey.getOwner())) + .collect(Collectors.toSet()); + } + return Collections.emptySet(); } diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java index c064e54ba7..7087a971a4 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java @@ -32,6 +32,6 @@ import sonia.scm.security.GPG; public class GPGModule extends AbstractModule { @Override protected void configure() { - bind(GPG.class).to(DummyGPG.class); + bind(GPG.class).to(DefaultGPG.class); } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java new file mode 100644 index 0000000000..26a903744b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java @@ -0,0 +1,102 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security.gpg; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPObjectFactory; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.operator.PGPContentVerifierBuilderProvider; +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.security.PublicKey; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +public class GpgKey implements PublicKey { + + private static final Logger LOG = LoggerFactory.getLogger(GpgKey.class); + + private final String id; + private final String owner; + + public GpgKey(String id, String owner) { + this.id = id; + this.owner = owner; + } + + @Override + public String getId() { + return id; + } + + @Override + public Optional getOwner() { + if (owner == null) { + return Optional.empty(); + } + return Optional.of(owner); + } + + @Override + public boolean verify(InputStream stream, byte[] signature) { + boolean verified = false; + try { + ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature)); + PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator()); + PGPPublicKey publicKey = ((PGPPublicKeyRing) pgpObjectFactory.nextObject()).getPublicKey(); + PGPSignature pgpSignature = ((PGPSignature) publicKey.getSignatures().next()); + + PGPContentVerifierBuilderProvider provider = new JcaPGPContentVerifierBuilderProvider(); + pgpSignature.init(provider, publicKey); + + char[] buffer = new char[1024]; + int bytesRead = 0; + BufferedReader in = new BufferedReader(new InputStreamReader(stream)); + + while (bytesRead != -1) { + bytesRead = in.read(buffer, 0, 1024); + pgpSignature.update(new String(buffer).getBytes(StandardCharsets.UTF_8)); + } + + verified = pgpSignature.verify(); + } catch (IOException | PGPException e) { + LOG.error("Could not verify GPG key", e); + } + return verified; + } +} + + diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java new file mode 100644 index 0000000000..63f39bd523 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java @@ -0,0 +1,86 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security.gpg; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.PublicKey; + +import java.io.IOException; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultGPGTest { + + @Mock + private PublicKeyStore store; + + @InjectMocks + private DefaultGPG gpg; + + @Test + void shouldFindIdInSignature() throws IOException { + String raw = GPGTestHelper.readKey("single.asc"); + String publicKeyId = gpg.findPublicKeyId(raw.getBytes()); + + assertThat(publicKeyId).isEqualTo("0x975922F193B07D6E"); + } + + @Test + void shouldFindPublicKey() { + RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", "raw", Instant.now()); + + when(store.findById("42")).thenReturn(Optional.of(key1)); + + Optional publicKey = gpg.findPublicKey("42"); + + assertThat(publicKey).isPresent(); + assertThat(publicKey.get().getOwner()).isPresent(); + assertThat(publicKey.get().getOwner().get()).contains("trillian"); + assertThat(publicKey.get().getId()).isEqualTo("42"); + } + + @Test + void shouldFindKeysForUsername() { + RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", "raw", Instant.now()); + RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", "raw", Instant.now()); + when(store.findByUsername("trillian")).thenReturn(ImmutableList.of(key1, key2)); + + Iterable keys = gpg.findPublicKeysByUsername("trillian"); + + assertThat(keys).hasSize(2); + PublicKey key = keys.iterator().next(); + assertThat(key.getOwner()).isPresent(); + assertThat(key.getOwner().get()).contains("trillian"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java new file mode 100644 index 0000000000..86bf5de4e0 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.security.gpg; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class GpgKeyTest { + + @Test + void shouldVerifyPublicKey() throws IOException { + StringBuilder longContent = new StringBuilder(); + for (int i = 1; i < 10000; i++) { + longContent.append(i); + } + + byte[] raw = GPGTestHelper.readKey("subkeys.asc").getBytes(); + + GpgKey key = new GpgKey("1", "trillian"); + + boolean verified = key.verify(longContent.toString().getBytes(), raw); + + // assertThat(verified).isTrue(); + } + +}