Make using Bouncy Castle with OpenPGP fun again!
How to be sure that you implement it correctly? #
In order to implement key generation for bouncy-gpg I had to make sure that everything I did was compatible with gnupg.
This meant for example that I needed to automatically run the following tests:
- Generate keys in
bouncy-gpg
and import them ingpg
- Encrypt messages in
bouncy-gpg
and decrypt them ingpg
- Encrypt messages in
gpg
and decrypt them inbouncy-gpg
All with different variants of keyrings, e.g.
- Only a master key with no subkeys
- Different subkeys
- RSA and ECC keys
- …
One of my tests looks like this:
- generate a keyring for Juliet Capulet with BouncyGPG,
- copy the public key to GPG,
- encrypt a message in GPG,
- and finally decrypt the message in BouncyGPG
The next test works the other way around, the private key is exported to gpg
and bouncy-gpg
encrypts to gpg
:
- generate a keyring for Juliet with BouncyGPG.
- copy the private key to GPG,
- encrypt a message in BouncyGPG,
- and finally decrypt the message in gpg
Generating keys in gpg
is not automated because nearly all unit tests use keys generated in gpg
.
These tests mean a lot of different gpg
calls, and even more parsing of gpg
output. Implementing this should make it easy to run commands, and to add new commands.
Example implementations #
In Java, we can use Runtime.getRuntime().exec()
or ProcessBuilder
to run external shell commands. The interesting part is that the call results need to be interpreted. This can get ugly pretty quick. To make the whole thing a bit more interesting we need to setup the gpg
home directory and configuration, and tear it down afterwards. Oh, and gpg
can be named gpg
or gpg2
.
Entangling test code and the driver for gpg
was not an option, so a command based architecture was chosen.
For example, here is how to import a secret key into gpg
:
final byte[] encoded = secretKeyRings.getEncoded();
assertEquals(0, gpg
.runCommand(
Commands.importKey(encoded, passphrase)
).exitCode());
Not very spectacular? Here is the whole flow to encrypt plaintext in gpg
and to read out the ciphertext:
private byte[] encryptMessageInGPG(final GPGExec gpg,
final String plaintext,
final String recipient) throws IOException, InterruptedException {
final EncryptCommandResult encryptCommandResult = gpg
.runCommand(Commands.encrypt(plaintext.getBytes(), recipient));
Assert.assertEquals(0, encryptCommandResult.exitCode());
return encryptCommandResult.getCiphertext();
}
The whole test suite can be found on GitHub.
Shelling out gpg #
gpg
is orchestrated via a command pattern architecture. The core functionality to shell out gpg
is located in GPGExec:
// IOSniffer wraps stdin, stdout and stderr and "tees" everything into logfiles
private IOSniffer gpg(Command<?> cmd) throws IOException, InterruptedException {
List<String> command = new ArrayList<>();
command.add(gpgExecutable());
command.add("--homedir");
command.add(homeDir.toAbsolutePath().toString());
command.addAll(cmd.getArgs());
ProcessBuilder pb =
new ProcessBuilder(command);
Map<String, String> env = pb.environment();
env.put("GNUPGHOME", homeDir.toAbsolutePath().toString());
pb
.redirectErrorStream(false)
.directory(homeDir.toFile());
Process p = pb.start();
IOSniffer sniffer = IOSniffer.wrapIO(p);
cmd.io(sniffer.getOutputStream(), sniffer.getInputStream(), sniffer.getErrorStream());
sniffer.getOutputStream().flush();
sniffer.getOutputStream().close();
p.waitFor(15, TimeUnit.SECONDS);
if (p.isAlive()) {
// hmm
LOGGER.warn("Forcibly destroy process " + String.join(" ", command));
p.destroyForcibly();
}
return sniffer;
}
As an example for commands, first the VersionCommand
. It runs gpg --version
and parses the gpg
version:
// the boring parts are deleted
public class VersionCommand implements Command {
// gpg (GnuPG/MacGPG2) 2.2.10
private Pattern VERSION_STRING = Pattern
.compile("^gpg.* (?<major>[\\d]+).(?<minor>[\\d]+).(?<revision>[\\d]+)$");
@Override
public List<String> getArgs() {
return asList("--version");
}
@Override
public VersionCommandResult parse(InputStream stdout, int exitCode) {
try (
Scanner sc = new Scanner(stdout)
) {
while (sc.hasNext()) {
final String line = sc.nextLine();
final Matcher matcher = VERSION_STRING.matcher(line);
if (matcher.matches()) {
final int major = Integer.parseInt(matcher.group("major"));
final int minor = Integer.parseInt(matcher.group("minor"));
final int revision = Integer.parseInt(matcher.group("revision"));
return new VersionCommandResult(exitCode, line, major, minor, revision);
}
}
}
return VersionCommandResult.UNKNOWN;
}
public final static class VersionCommandResult implements Result<VersionCommand> {
public final static VersionCommandResult UNKNOWN = new VersionCommandResult(-1, "", 0, 0, 0);
private final int exitCode;
private final String versionString;
private final int major;
private final int minor;
private final int revision;
private VersionCommandResult(final int exitCode, final String versionString, final int major,
final int minor,
final int revision) {
// [...]
}
// [...]
public boolean isAtLeast(int major) {
return this.major >= major;
}
public boolean isAtLeast(int major, int minor) {
return (this.major > major) || (this.major == major && this.minor >= minor);
}
@Override
public int exitCode() {
return exitCode;
}
// [...]
}
}
The command to import keys is not that different:
public class ImportCommand implements Command {
private final byte[] keyData;
private final String passphrase;
// [...]
@Override
public List<String> getArgs() {
final List<String> args = new ArrayList<>(asList("--import",
"--batch"));
if (passphrase != null) {
args.add("--passphrase");
args.add(passphrase);
}
return args;
}
public void io(OutputStream outputStream, InputStream inputStream, InputStream errorStream)
throws IOException {
outputStream.write(keyData);
outputStream.close();
}
@Override
public ImportCommandResult parse(InputStream stdout, int exitCode) {
// nothing to do
String output;
try {
final byte[] bytes = Streams.readAll(stdout);
output = new String(bytes);
} catch (IOException e) {
output = e.getMessage();
}
return new ImportCommandResult(exitCode, output);
}
public final static class ImportCommandResult implements Result<ImportCommand> {
// [...]
@Override
public int exitCode() {
return exitCode;
}
// [...]
}
}
Was it worth it? #
Shelling out executables from Java feels plain wrong. But in this case it was necessary and greatly improved my confidence that gpg
and bouncy-gpg
are compatible. I found out that generated ECC keys are not compatible with gpg
, and found out that RSA works very good, no matter where the keys are generated.
Considering that these are “just” integration tests, a lot of work went into writing the driver for gpg
. But it was worth it.