1
0
Fork 0
mirror of https://github.com/pgpainless/pgpainless.git synced 2025-09-09 18:29:39 +02:00

Add OpenPGPCertificateUtil and unify the way, SOP encodes/armors certificates/keys

This commit is contained in:
Paul Schaub 2025-05-14 13:27:06 +02:00
parent b8841a4415
commit 92d66f7f30
Signed by: vanitasvitae
GPG key ID: 62BEE9264BF17311
10 changed files with 260 additions and 105 deletions

View file

@ -4,6 +4,9 @@
package org.pgpainless.bouncycastle.extensions package org.pgpainless.bouncycastle.extensions
import java.io.OutputStream
import org.bouncycastle.bcpg.ArmoredOutputStream
import org.bouncycastle.bcpg.PacketFormat
import org.bouncycastle.openpgp.PGPOnePassSignature import org.bouncycastle.openpgp.PGPOnePassSignature
import org.bouncycastle.openpgp.api.OpenPGPCertificate import org.bouncycastle.openpgp.api.OpenPGPCertificate
import org.bouncycastle.openpgp.api.OpenPGPCertificate.OpenPGPComponentKey import org.bouncycastle.openpgp.api.OpenPGPCertificate.OpenPGPComponentKey
@ -22,3 +25,38 @@ fun OpenPGPCertificate.getKeyVersion(): OpenPGPKeyVersion = primaryKey.getKeyVer
/** Return the [OpenPGPKeyVersion] of the component key. */ /** Return the [OpenPGPKeyVersion] of the component key. */
fun OpenPGPComponentKey.getKeyVersion(): OpenPGPKeyVersion = OpenPGPKeyVersion.from(this.version) fun OpenPGPComponentKey.getKeyVersion(): OpenPGPKeyVersion = OpenPGPKeyVersion.from(this.version)
/**
* ASCII-armor-encode the certificate into the given [OutputStream].
*
* @param outputStream output stream
* @param format packet length encoding format, defaults to [PacketFormat.ROUNDTRIP]
*/
fun OpenPGPCertificate.asciiArmor(
outputStream: OutputStream,
format: PacketFormat = PacketFormat.ROUNDTRIP
) {
outputStream.write(toAsciiArmoredString(format).encodeToByteArray())
}
/**
* ASCII-armor-encode the certificate into the given [OutputStream].
*
* @param outputStream output stream
* @param format packet length encoding format, defaults to [PacketFormat.ROUNDTRIP]
* @param armorBuilder builder for the ASCII armored output stream
*/
fun OpenPGPCertificate.asciiArmor(
outputStream: OutputStream,
format: PacketFormat,
armorBuilder: ArmoredOutputStream.Builder
) {
outputStream.write(toAsciiArmoredString(format, armorBuilder).encodeToByteArray())
}
fun OpenPGPCertificate.encode(
outputStream: OutputStream,
format: PacketFormat = PacketFormat.ROUNDTRIP
) {
outputStream.write(getEncoded(format))
}

View file

@ -1,4 +1,49 @@
// SPDX-FileCopyrightText: 2025 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package org.pgpainless.util package org.pgpainless.util
class OpenPGPCertificateUtil { import java.io.OutputStream
import org.bouncycastle.bcpg.ArmoredOutputStream
import org.bouncycastle.bcpg.PacketFormat
import org.bouncycastle.openpgp.api.OpenPGPCertificate
import org.pgpainless.bouncycastle.extensions.asciiArmor
import org.pgpainless.bouncycastle.extensions.encode
class OpenPGPCertificateUtil private constructor() {
companion object {
@JvmStatic
@JvmOverloads
fun encode(
certs: Collection<OpenPGPCertificate>,
outputStream: OutputStream,
packetFormat: PacketFormat = PacketFormat.ROUNDTRIP
) {
for (cert in certs) {
cert.encode(outputStream, packetFormat)
}
}
@JvmStatic
@JvmOverloads
fun armor(
certs: Collection<OpenPGPCertificate>,
outputStream: OutputStream,
packetFormat: PacketFormat = PacketFormat.ROUNDTRIP
) {
if (certs.size == 1) {
// Add pretty armor header to single cert/key
certs.iterator().next().asciiArmor(outputStream, packetFormat)
} else {
// Do not add a pretty header
val aOut = ArmoredOutputStream(outputStream)
for (cert in certs) {
cert.encode(aOut, packetFormat)
}
aOut.close()
}
}
}
} }

View file

@ -1,4 +1,118 @@
// SPDX-FileCopyrightText: 2025 Paul Schaub <vanitasvitae@fsfe.org>
//
// SPDX-License-Identifier: Apache-2.0
package org.pgpainless.util; package org.pgpainless.util;
import org.bouncycastle.bcpg.PacketFormat;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.pgpainless.PGPainless;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class OpenPGPCertificateUtilTest { public class OpenPGPCertificateUtilTest {
@TestTemplate
@ExtendWith(TestAllImplementations.class)
public void testEncodeSingleCert() {
PGPainless api = PGPainless.getInstance();
List<OpenPGPCertificate> certs = new ArrayList<>();
certs.add(api.generateKey().modernKeyRing("Alice <alice@pgpainless.org>").toCertificate());
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
OpenPGPCertificateUtil.armor(certs, bOut, PacketFormat.CURRENT);
String armor = bOut.toString();
assertTrue(armor.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: "),
"For a single cert, the ASCII armor MUST contain a comment with the fingerprint");
}
@TestTemplate
@ExtendWith(TestAllImplementations.class)
public void testEncodeSingleKey() {
PGPainless api = PGPainless.getInstance();
List<OpenPGPCertificate> certs = new ArrayList<>();
certs.add(api.generateKey().modernKeyRing("Alice <alice@pgpainless.org>"));
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
OpenPGPCertificateUtil.armor(certs, bOut, PacketFormat.CURRENT);
String armor = bOut.toString();
assertTrue(armor.startsWith("-----BEGIN PGP PRIVATE KEY BLOCK-----\nComment: "),
"For a single key, the ASCII armor MUST contain a comment with the fingerprint");
}
@TestTemplate
@ExtendWith(TestAllImplementations.class)
public void testEncodeTwoCerts() {
PGPainless api = PGPainless.getInstance();
List<OpenPGPCertificate> certs = new ArrayList<>();
certs.add(api.generateKey().modernKeyRing("Alice <alice@pgpainless.org>").toCertificate());
certs.add(api.generateKey().modernKeyRing("Bob <bob@pgpainless.org>").toCertificate());
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
OpenPGPCertificateUtil.armor(certs, bOut, PacketFormat.CURRENT);
String armor = bOut.toString();
assertTrue(armor.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
assertEquals(
armor.indexOf("-----BEGIN PGP PUBLIC KEY BLOCK-----"),
armor.lastIndexOf("-----BEGIN PGP PUBLIC KEY BLOCK-----"),
"There MUST only be a single block in the armor.");
assertFalse(armor.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: "),
"For multiple certs, the ASCII armor MUST NOT contain a comment containing the fingerprint");
}
@TestTemplate
@ExtendWith(TestAllImplementations.class)
public void testEncodeCertAndKey() {
PGPainless api = PGPainless.getInstance();
List<OpenPGPCertificate> certs = new ArrayList<>();
certs.add(api.generateKey().modernKeyRing("Alice <alice@pgpainless.org>").toCertificate());
certs.add(api.generateKey().modernKeyRing("Bob <bob@pgpainless.org>"));
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
OpenPGPCertificateUtil.armor(certs, bOut, PacketFormat.CURRENT);
String armor = bOut.toString();
assertTrue(armor.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
assertEquals(
armor.indexOf("-----BEGIN PGP PUBLIC KEY BLOCK-----"),
armor.lastIndexOf("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
assertFalse(armor.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: "),
"For multiple certs/keys, the ASCII armor MUST NOT contain a comment containing the fingerprint");
}
@TestTemplate
@ExtendWith(TestAllImplementations.class)
public void testEncodeKeyAndCert() {
PGPainless api = PGPainless.getInstance();
List<OpenPGPCertificate> certs = new ArrayList<>();
certs.add(api.generateKey().modernKeyRing("Alice <alice@pgpainless.org>"));
certs.add(api.generateKey().modernKeyRing("Bob <bob@pgpainless.org>").toCertificate());
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
OpenPGPCertificateUtil.armor(certs, bOut, PacketFormat.CURRENT);
String armor = bOut.toString();
assertTrue(armor.startsWith("-----BEGIN PGP PRIVATE KEY BLOCK-----"));
assertEquals(
armor.indexOf("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
armor.lastIndexOf("-----BEGIN PGP PRIVATE KEY BLOCK-----"));
assertFalse(armor.startsWith("-----BEGIN PGP PRIVATE KEY BLOCK-----\nComment: "),
"For multiple certs, the ASCII armor MUST NOT contain a comment containing the fingerprint");
}
} }

View file

@ -6,11 +6,10 @@ package org.pgpainless.sop
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import org.bouncycastle.bcpg.PacketFormat
import org.bouncycastle.openpgp.api.OpenPGPKey import org.bouncycastle.openpgp.api.OpenPGPKey
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.exception.KeyException import org.pgpainless.exception.KeyException
import org.pgpainless.util.ArmoredOutputStreamFactory import org.pgpainless.util.OpenPGPCertificateUtil
import org.pgpainless.util.Passphrase import org.pgpainless.util.Passphrase
import sop.Ready import sop.Ready
import sop.exception.SOPGPException import sop.exception.SOPGPException
@ -27,45 +26,43 @@ class CertifyUserIdImpl(private val api: PGPainless) : CertifyUserId {
override fun certs(certs: InputStream): Ready { override fun certs(certs: InputStream): Ready {
return object : Ready() { return object : Ready() {
override fun writeTo(outputStream: OutputStream) { override fun writeTo(outputStream: OutputStream) {
val out =
if (armor) {
ArmoredOutputStreamFactory.get(outputStream)
} else outputStream
api.readKey() val certificates =
.parseCertificates(certs) api.readKey()
.onEach { cert -> .parseCertificates(certs)
if (requireSelfSig) { .onEach { cert ->
// Check for non-bound user-ids if (requireSelfSig) {
userIds // Check for non-bound user-ids
.find { cert.getUserId(it)?.isBound != true } userIds
?.let { throw SOPGPException.CertUserIdNoMatch(cert.fingerprint) } .find { cert.getUserId(it)?.isBound != true }
} ?.let {
} throw SOPGPException.CertUserIdNoMatch(cert.fingerprint)
.forEach { cert -> }
var certificate = cert
keys.forEach { key ->
userIds.forEach { userId ->
try {
certificate =
api.generateCertification()
.certifyUserId(userId, certificate)
.withKey(key, protector)
.build()
.certifiedCertificate
} catch (e: KeyException) {
throw SOPGPException.KeyCannotCertify(e)
}
} }
} }
.map { cert ->
var certificate = cert
keys.forEach { key ->
userIds.forEach { userId ->
try {
certificate =
api.generateCertification()
.certifyUserId(userId, certificate)
.withKey(key, protector)
.build()
.certifiedCertificate
} catch (e: KeyException) {
throw SOPGPException.KeyCannotCertify(e)
}
}
}
certificate
}
out.write(certificate.getEncoded(PacketFormat.CURRENT))
}
out.close()
if (armor) { if (armor) {
// armored output stream does not close inner stream OpenPGPCertificateUtil.armor(certificates, outputStream)
outputStream.close() } else {
OpenPGPCertificateUtil.encode(certificates, outputStream)
} }
} }
} }

View file

@ -8,12 +8,11 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import org.bouncycastle.openpgp.PGPException import org.bouncycastle.openpgp.PGPException
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.exception.MissingPassphraseException import org.pgpainless.exception.MissingPassphraseException
import org.pgpainless.key.protection.SecretKeyRingProtector import org.pgpainless.key.protection.SecretKeyRingProtector
import org.pgpainless.key.util.KeyRingUtils import org.pgpainless.key.util.KeyRingUtils
import org.pgpainless.util.ArmoredOutputStreamFactory import org.pgpainless.util.OpenPGPCertificateUtil
import org.pgpainless.util.Passphrase import org.pgpainless.util.Passphrase
import sop.Ready import sop.Ready
import sop.exception.SOPGPException import sop.exception.SOPGPException
@ -54,16 +53,14 @@ class ChangeKeyPasswordImpl(private val api: PGPainless) : ChangeKeyPassword {
"Cannot change passphrase of key ${it.keyIdentifier}", e) "Cannot change passphrase of key ${it.keyIdentifier}", e)
} }
} }
.let { PGPSecretKeyRingCollection(it) } .map { api.toKey(it) }
return object : Ready() { return object : Ready() {
override fun writeTo(outputStream: OutputStream) { override fun writeTo(outputStream: OutputStream) {
if (armor) { if (armor) {
ArmoredOutputStreamFactory.get(outputStream).use { OpenPGPCertificateUtil.armor(updatedSecretKeys, outputStream)
updatedSecretKeys.encode(it)
}
} else { } else {
updatedSecretKeys.encode(outputStream) OpenPGPCertificateUtil.encode(updatedSecretKeys, outputStream)
} }
} }
} }

View file

@ -7,7 +7,7 @@ package org.pgpainless.sop
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.util.ArmoredOutputStreamFactory import org.pgpainless.util.OpenPGPCertificateUtil
import sop.Ready import sop.Ready
import sop.operation.ExtractCert import sop.operation.ExtractCert
@ -22,19 +22,9 @@ class ExtractCertImpl(private val api: PGPainless) : ExtractCert {
return object : Ready() { return object : Ready() {
override fun writeTo(outputStream: OutputStream) { override fun writeTo(outputStream: OutputStream) {
if (armor) { if (armor) {
if (certs.size == 1) { OpenPGPCertificateUtil.armor(certs, outputStream)
val cert = certs[0]
// This way we have a nice armor header with fingerprint and user-ids
val armored = cert.toAsciiArmoredString()
outputStream.write(armored.toByteArray())
} else {
// for multiple certs, add no info headers to the ASCII armor
val armorOut = ArmoredOutputStreamFactory.get(outputStream)
certs.forEach { armorOut.write(it.encoded) }
armorOut.close()
}
} else { } else {
certs.forEach { outputStream.write(it.encoded) } OpenPGPCertificateUtil.encode(certs, outputStream)
} }
} }
} }

View file

@ -13,6 +13,8 @@ import org.bouncycastle.openpgp.PGPException
import org.bouncycastle.openpgp.api.OpenPGPKey import org.bouncycastle.openpgp.api.OpenPGPKey
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.algorithm.KeyFlag import org.pgpainless.algorithm.KeyFlag
import org.pgpainless.bouncycastle.extensions.asciiArmor
import org.pgpainless.bouncycastle.extensions.encode
import org.pgpainless.key.generation.KeyRingBuilder import org.pgpainless.key.generation.KeyRingBuilder
import org.pgpainless.key.generation.KeySpec import org.pgpainless.key.generation.KeySpec
import org.pgpainless.key.generation.type.KeyType import org.pgpainless.key.generation.type.KeyType
@ -50,10 +52,9 @@ class GenerateKeyImpl(private val api: PGPainless) : GenerateKey {
return object : Ready() { return object : Ready() {
override fun writeTo(outputStream: OutputStream) { override fun writeTo(outputStream: OutputStream) {
if (armor) { if (armor) {
val armored = key.toAsciiArmoredString(PacketFormat.CURRENT) key.asciiArmor(outputStream, PacketFormat.CURRENT)
outputStream.write(armored.toByteArray())
} else { } else {
outputStream.write(key.getEncoded(PacketFormat.CURRENT)) key.encode(outputStream, PacketFormat.CURRENT)
} }
} }
} }

View file

@ -9,7 +9,7 @@ import java.io.OutputStream
import org.bouncycastle.bcpg.KeyIdentifier import org.bouncycastle.bcpg.KeyIdentifier
import org.bouncycastle.openpgp.api.OpenPGPCertificate import org.bouncycastle.openpgp.api.OpenPGPCertificate
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.util.ArmoredOutputStreamFactory import org.pgpainless.util.OpenPGPCertificateUtil
import sop.Ready import sop.Ready
import sop.operation.MergeCerts import sop.operation.MergeCerts
@ -46,22 +46,11 @@ class MergeCertsImpl(private val api: PGPainless) : MergeCerts {
baseCerts[update.keyIdentifier] = api.mergeCertificate(baseCert, update) baseCerts[update.keyIdentifier] = api.mergeCertificate(baseCert, update)
} }
val out =
if (armor) {
ArmoredOutputStreamFactory.get(outputStream)
} else {
outputStream
}
// emit merged and updated base certs
for (merged in baseCerts.values) {
out.write(merged.getEncoded())
}
if (armor) { if (armor) {
out.close() OpenPGPCertificateUtil.armor(baseCerts.values, outputStream)
} else {
OpenPGPCertificateUtil.encode(baseCerts.values, outputStream)
} }
outputStream.close()
} }
} }
} }

View file

@ -14,7 +14,7 @@ import org.pgpainless.bouncycastle.extensions.toOpenPGPCertificate
import org.pgpainless.exception.WrongPassphraseException import org.pgpainless.exception.WrongPassphraseException
import org.pgpainless.key.util.KeyRingUtils import org.pgpainless.key.util.KeyRingUtils
import org.pgpainless.key.util.RevocationAttributes import org.pgpainless.key.util.RevocationAttributes
import org.pgpainless.util.ArmoredOutputStreamFactory import org.pgpainless.util.OpenPGPCertificateUtil
import org.pgpainless.util.Passphrase import org.pgpainless.util.Passphrase
import sop.Ready import sop.Ready
import sop.exception.SOPGPException import sop.exception.SOPGPException
@ -67,15 +67,9 @@ class RevokeKeyImpl(private val api: PGPainless) : RevokeKey {
return object : Ready() { return object : Ready() {
override fun writeTo(outputStream: OutputStream) { override fun writeTo(outputStream: OutputStream) {
if (armor) { if (armor) {
val armorOut = ArmoredOutputStreamFactory.get(outputStream) OpenPGPCertificateUtil.armor(revocationCertificates, outputStream)
for (cert in revocationCertificates) {
armorOut.write(cert.getEncoded())
}
armorOut.close()
} else { } else {
for (cert in revocationCertificates) { OpenPGPCertificateUtil.encode(revocationCertificates, outputStream)
outputStream.write(cert.getEncoded())
}
} }
} }
} }

View file

@ -7,12 +7,10 @@ package org.pgpainless.sop
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import org.bouncycastle.bcpg.KeyIdentifier import org.bouncycastle.bcpg.KeyIdentifier
import org.bouncycastle.bcpg.PacketFormat
import org.bouncycastle.openpgp.PGPSecretKeyRing import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.bouncycastle.openpgp.api.OpenPGPCertificate import org.bouncycastle.openpgp.api.OpenPGPCertificate
import org.bouncycastle.openpgp.api.OpenPGPKey
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.util.ArmoredOutputStreamFactory import org.pgpainless.util.OpenPGPCertificateUtil
import org.pgpainless.util.Passphrase import org.pgpainless.util.Passphrase
import sop.Ready import sop.Ready
import sop.operation.UpdateKey import sop.operation.UpdateKey
@ -29,33 +27,25 @@ class UpdateKeyImpl(private val api: PGPainless) : UpdateKey {
override fun key(key: InputStream): Ready { override fun key(key: InputStream): Ready {
return object : Ready() { return object : Ready() {
override fun writeTo(outputStream: OutputStream) { override fun writeTo(outputStream: OutputStream) {
val out = val keyList =
if (armor) { api.readKey().parseKeys(key).map {
ArmoredOutputStreamFactory.get(outputStream) if (mergeCerts[it.keyIdentifier] == null) {
} else { it
outputStream
}
val keyList = api.readKey().parseKeys(key)
for (k in keyList) {
val updatedKey: OpenPGPKey =
if (mergeCerts[k.keyIdentifier] == null) {
k
} else { } else {
val updatedCert: OpenPGPCertificate = val updatedCert: OpenPGPCertificate =
api.mergeCertificate( api.mergeCertificate(
k.toCertificate(), mergeCerts[k.keyIdentifier]!!) it.toCertificate(), mergeCerts[it.keyIdentifier]!!)
api.toKey( api.toKey(
PGPSecretKeyRing.replacePublicKeys( PGPSecretKeyRing.replacePublicKeys(
k.pgpSecretKeyRing, updatedCert.pgpPublicKeyRing)) it.pgpSecretKeyRing, updatedCert.pgpPublicKeyRing))
} }
out.write(updatedKey.getEncoded(PacketFormat.CURRENT)) }
}
if (armor) { if (armor) {
out.close() OpenPGPCertificateUtil.armor(keyList, outputStream)
} else {
OpenPGPCertificateUtil.encode(keyList, outputStream)
} }
outputStream.close()
} }
} }
} }