Skip to content

Commit

Permalink
Add support for subkeys
Browse files Browse the repository at this point in the history
Closes gh-3
  • Loading branch information
wilkinsona committed Nov 19, 2024
1 parent 9052d84 commit 3701fa3
Show file tree
Hide file tree
Showing 11 changed files with 379 additions and 105 deletions.
2 changes: 2 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ It makes use of the "builds" and "artifact properties" features of Artifactory t
Defaults to 1
- `signing-key`: A PGP/GPG signing key that will be used to sign artifacts before they are deployed
- `signing-passphrase`: Passphrase of the signing key
- `signing-key-id`: ID of the signing key that will be used.
When omitted, the first key that is a signing key will be used



Expand Down
4 changes: 4 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ inputs:
signing-passphrase:
description: 'Passphrase of the signing key'
required: false
signing-key-id:
description: 'ID of the signing key that will be used. When omitted, the first key that is a signing key will be used'
required: false
artifact-properties:
description: 'Properties to apply to the deployed artifacts. Each line should be of the form
`<includes>:<excludes>:<properties>. includes and excludes are comma-separated Ant patterns.
Expand All @@ -60,3 +63,4 @@ runs:
- --artifactory.deploy.threads=${{ inputs.threads }}
- --artifactory.signing.key=${{ inputs.signing-key }}
- --artifactory.signing.passphrase=${{ inputs.signing-passphrase }}
- --artifactory.signing.key-id=${{ inputs.signing-key-id }}
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,43 @@ void deployWithSignatures(@TempDir File temp) throws IOException {
assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules.[0].artifacts").hasSize(4);
}

@Test
void deployWithSignaturesFromSubkey(@TempDir File temp) throws IOException {
File example = new File(temp, "com/example/module/1.0.0");
example.mkdirs();
Files.writeString(new File(example, "module-1.0.0.jar").toPath(), "jar-file-content");
Files.writeString(new File(example, "module-1.0.0.pom").toPath(), "pom-file-content");
ArtifactoryDeploy.main(new String[] {
String.format("--artifactory.server.uri=http://%s:%s/artifactory", container.getHost(),
container.getFirstMappedPort()),
"--artifactory.server.username=admin", "--artifactory.server.password=password",
"--artifactory.deploy.repository=example-repo-local", "--artifactory.deploy.build.number=15",
"--artifactory.deploy.build.name=integration-test", "--artifactory.deploy.folder=" + temp,
"--artifactory.deploy.threads=2",
"--artifactory.signing.key=" + Files.readString(Path.of("src", "test", "resources", "io", "spring",
"github", "actions", "artifactorydeploy", "openpgp", "test-private-subkey.txt")),
"--artifactory.signing.passphrase=password", "--artifactory.signing.key-id=C3E2E826" });

RestTemplate rest = new RestTemplateBuilder().basicAuthentication("admin", "password")
.rootUri("http://%s:%s/artifactory/".formatted(container.getHost(), container.getFirstMappedPort()))
.build();
assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.jar", String.class))
.isEqualTo("jar-file-content");
assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.pom", String.class))
.isEqualTo("pom-file-content");
assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.jar.asc", byte[].class))
.isNotEmpty();
assertThat(rest.getForObject("/example-repo-local/com/example/module/1.0.0/module-1.0.0.pom.asc", byte[].class))
.isNotEmpty();
String buildInfo = rest.getForObject("/api/build/integration-test/15", String.class);
BasicJsonTester jsonTester = new BasicJsonTester(getClass());
JsonContent<?> buildInfoJson = jsonTester.from(buildInfo);
assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.name").isEqualTo("integration-test");
assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.number").isEqualTo("15");
assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.buildAgent.name").isEqualTo("Artifactory Action");
assertThat(buildInfoJson).extractingJsonPathValue("buildInfo.agent.name").isEqualTo("GitHub Actions");
assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules").hasSize(1);
assertThat(buildInfoJson).extractingJsonPathArrayValue("buildInfo.modules.[0].artifacts").hasSize(4);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public Server(URI uri, String username, String password) {

}

public record Signing(String key, String passphrase) {
public record Signing(String key, String passphrase, String keyId) {
}

public record Deploy(String project, String folder, String repository, int threads, Deploy.Build build,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,15 @@ private MultiValueMap<Category, DeployableArtifact> signArtifactsIfNecessary(
if (signing == null || !StringUtils.hasText(signing.key())) {
return batchedArtifacts;
}
return signArtifacts(batchedArtifacts, signing.key(), signing.passphrase(), buildProperties);
return signArtifacts(batchedArtifacts, signing.key(), signing.passphrase(), signing.keyId(), buildProperties);
}

private MultiValueMap<Category, DeployableArtifact> signArtifacts(
MultiValueMap<Category, DeployableArtifact> batchedArtifacts, String signingKey, String signingPassphrase,
Map<String, String> buildProperties) {
String signingKeyId, Map<String, String> buildProperties) {
try {
console.log("Signing artifacts");
ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(signingKey, signingPassphrase);
ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(signingKey, signingPassphrase, signingKeyId);
return new DeployableArtifactsSigner(signer, buildProperties).addSignatures(batchedArtifacts);
}
catch (IOException ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,37 +74,44 @@ public final class ArmoredAsciiSigner {

private final Clock clock;

private ArmoredAsciiSigner(Clock clock, InputStream signingKeyInputStream, String passphrase) {
PGPSecretKey signingKey = getSigningKey(signingKeyInputStream);
private ArmoredAsciiSigner(Clock clock, InputStream signingKeyInputStream, String passphrase, String keyId) {
PGPSecretKey signingKey = getSigningKey(signingKeyInputStream, keyId);
this.clock = clock;
this.signingKey = signingKey;
this.privateKey = extractPrivateKey(passphrase, signingKey);
this.contentSigner = getContentSigner(signingKey.getPublicKey().getAlgorithm());
}

private PGPSecretKey getSigningKey(InputStream inputStream) {
private PGPSecretKey getSigningKey(InputStream inputStream, String keyId) {
try {
try (InputStream decoderStream = PGPUtil.getDecoderStream(inputStream)) {
PGPSecretKeyRingCollection keyrings = new PGPSecretKeyRingCollection(decoderStream,
FINGERPRINT_CALCULATOR);
return getSigningKey(keyrings);
return getSigningKey(keyrings, keyId);
}
}
catch (Exception ex) {
throw new IllegalStateException("Unable to read signing key", ex);
}
}

private PGPSecretKey getSigningKey(PGPSecretKeyRingCollection keyrings) {
private PGPSecretKey getSigningKey(PGPSecretKeyRingCollection keyrings, String keyId) {
for (PGPSecretKeyRing keyring : keyrings) {
Iterable<PGPSecretKey> secretKeys = keyring::getSecretKeys;
for (PGPSecretKey candidate : secretKeys) {
if (candidate.isSigningKey()) {
if (keyId != null) {
String candidateKeyId = String.format("%08X", 0xFFFFFFFFL & candidate.getKeyID());
if (keyId.equals(candidateKeyId)) {
return candidate;
}
}
else if (candidate.isSigningKey()) {
return candidate;
}
}
}
throw new IllegalArgumentException("Keyring does not contain a suitable signing key");
throw new IllegalArgumentException((keyId != null) ? "Keyring does not contain key '%s'".formatted(keyId)
: "Keyring does not contain a suitable signing key");
}

private PGPPrivateKey extractPrivateKey(String passphrase, PGPSecretKey signingKey) {
Expand Down Expand Up @@ -212,30 +219,33 @@ private void updateSignatureGenerator(InputStream source, PGPSignatureGenerator
}

/**
* Get an {@link ArmoredAsciiSigner} for the given {@code signingKey} and
* {@code passphrase}. The signing key may either contain a PGP private key block or
* reference a file.
* Get an {@link ArmoredAsciiSigner} for the given {@code signingKey},
* {@code passphrase}, and {@code keyId}. The signing key may either contain a PGP
* private key block or reference a file. The key with the given {@code keyId} will be
* used for signing. If {@code keyId} is {@code null} that first key that is a
* {@link PGPSecretKey#isSigningKey() is a signing key} will be used.
* @param signingKey the signing key (either the key itself or a reference to a file)
* @param passphrase the passphrase to use
* @param keyId the ID of the key to use
* @return an {@link ArmoredAsciiSigner} insance
* @throws IOException on IO error
*/
public static ArmoredAsciiSigner get(String signingKey, String passphrase) throws IOException {
return get(Clock.systemDefaultZone(), signingKey, passphrase);
public static ArmoredAsciiSigner get(String signingKey, String passphrase, String keyId) throws IOException {
return get(Clock.systemDefaultZone(), signingKey, passphrase, keyId);
}

static ArmoredAsciiSigner get(Clock clock, String signingKey, String passphrase) throws IOException {
static ArmoredAsciiSigner get(Clock clock, String signingKey, String passphrase, String keyId) throws IOException {
Assert.notNull(clock, "Clock must not be null");
Assert.notNull(signingKey, "SigningKey must not be null");
Assert.hasText(signingKey, "SigningKey must not be empty");
Assert.notNull(passphrase, "Passphrase must not be null");
if (isArmoredAscii(signingKey)) {
byte[] bytes = signingKey.getBytes(StandardCharsets.UTF_8);
return new ArmoredAsciiSigner(clock, new ByteArrayInputStream(bytes), passphrase);
return new ArmoredAsciiSigner(clock, new ByteArrayInputStream(bytes), passphrase, keyId);
}
Assert.isTrue(!signingKey.contains("\n"),
"Signing key does not contain a PGP private key block and does not reference a file");
return new ArmoredAsciiSigner(clock, new FileInputStream(signingKey), passphrase);
return new ArmoredAsciiSigner(clock, new FileInputStream(signingKey), passphrase, keyId);
}

private static boolean isArmoredAscii(String signingKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class DeployableArtifactsSignerTests {
void setup() throws IOException {
String signingKey = new String(ArmoredAsciiSigner.class.getResourceAsStream("test-private.txt").readAllBytes(),
StandardCharsets.UTF_8);
ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(signingKey, "password");
ArmoredAsciiSigner signer = ArmoredAsciiSigner.get(signingKey, "password", null);
this.signer = new DeployableArtifactsSigner(signer, this.properties);
}

Expand Down
Loading

0 comments on commit 3701fa3

Please sign in to comment.