diff --git a/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditor.java b/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditor.java index 7418401f..e1b1b23e 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditor.java +++ b/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditor.java @@ -205,12 +205,61 @@ public class SecretKeyRingEditor implements SecretKeyRingEditorInterface { @Override public SecretKeyRingEditorInterface removeUserId( CharSequence userId, - SecretKeyRingProtector protector) throws PGPException { + SecretKeyRingProtector protector) + throws PGPException { return removeUserId( SelectUserId.exactMatch(userId.toString()), protector); } + @Override + public SecretKeyRingEditorInterface replaceUserId(@Nonnull CharSequence oldUserId, + @Nonnull CharSequence newUserId, + @Nonnull SecretKeyRingProtector protector) + throws PGPException { + String oldUID = oldUserId.toString().trim(); + String newUID = newUserId.toString().trim(); + if (oldUID.isEmpty()) { + throw new IllegalArgumentException("Old user-id cannot be empty."); + } + + if (newUID.isEmpty()) { + throw new IllegalArgumentException("New user-id cannot be empty."); + } + + KeyRingInfo info = PGPainless.inspectKeyRing(secretKeyRing); + if (!info.isUserIdValid(oldUID)) { + throw new NoSuchElementException("Key does not carry user-id '" + oldUID + "', or it is not valid."); + } + + PGPSignature oldCertification = info.getLatestUserIdCertification(oldUID); + if (oldCertification == null) { + throw new AssertionError("Certification for old user-id MUST NOT be null."); + } + + // Bind new user-id + addUserId(newUserId, new SelfSignatureSubpackets.Callback() { + @Override + public void modifyHashedSubpackets(SelfSignatureSubpackets hashedSubpackets) { + SignatureSubpacketsHelper.applyFrom(oldCertification.getHashedSubPackets(), (SignatureSubpackets) hashedSubpackets); + // Primary user-id + if (oldUID.equals(info.getPrimaryUserId())) { + // Implicit primary user-id + if (!oldCertification.getHashedSubPackets().isPrimaryUserID()) { + hashedSubpackets.setPrimaryUserId(); + } + } + } + + @Override + public void modifyUnhashedSubpackets(SelfSignatureSubpackets unhashedSubpackets) { + SignatureSubpacketsHelper.applyFrom(oldCertification.getUnhashedSubPackets(), (SignatureSubpackets) unhashedSubpackets); + } + }, protector); + + return revokeUserId(oldUID, protector); + } + // TODO: Move to utility class? private String sanitizeUserId(@Nonnull CharSequence userId) { // TODO: Further research how to sanitize user IDs. diff --git a/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditorInterface.java b/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditorInterface.java index 6f4d34b8..7014d518 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditorInterface.java +++ b/pgpainless-core/src/main/java/org/pgpainless/key/modification/secretkeyring/SecretKeyRingEditorInterface.java @@ -104,6 +104,26 @@ public interface SecretKeyRingEditorInterface { SecretKeyRingProtector protector) throws PGPException; + /** + * Replace a user-id on the key with a new one. + * The old user-id gets soft revoked and the new user-id gets bound with the same signature subpackets as the + * old one, with one exception: + * If the old user-id was implicitly primary (did not carry a {@link org.bouncycastle.bcpg.sig.PrimaryUserID} packet, + * but effectively was primary, then the new user-id will be explicitly marked as primary. + * + * @param oldUserId old user-id + * @param newUserId new user-id + * @param protector protector to unlock the secret key + * @return the builder + * @throws PGPException in case we cannot generate a revocation and certification signature + * @throws java.util.NoSuchElementException if the old user-id was not found on the key; or if the oldUserId + * was already invalid + */ + SecretKeyRingEditorInterface replaceUserId(CharSequence oldUserId, + CharSequence newUserId, + SecretKeyRingProtector protector) + throws PGPException; + /** * Add a subkey to the key ring. * The subkey will be generated from the provided {@link KeySpec}. diff --git a/pgpainless-core/src/test/java/org/pgpainless/key/modification/FixUserIdDoesNotBreakEncryptionCapabilityTest.java b/pgpainless-core/src/test/java/org/pgpainless/key/modification/FixUserIdDoesNotBreakEncryptionCapabilityTest.java index 1995b8bf..e0429287 100644 --- a/pgpainless-core/src/test/java/org/pgpainless/key/modification/FixUserIdDoesNotBreakEncryptionCapabilityTest.java +++ b/pgpainless-core/src/test/java/org/pgpainless/key/modification/FixUserIdDoesNotBreakEncryptionCapabilityTest.java @@ -5,16 +5,31 @@ package org.pgpainless.key.modification; import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.util.io.Streams; import org.junit.jupiter.api.Test; import org.pgpainless.PGPainless; +import org.pgpainless.decryption_verification.ConsumerOptions; +import org.pgpainless.decryption_verification.DecryptionStream; +import org.pgpainless.decryption_verification.OpenPgpMetadata; +import org.pgpainless.encryption_signing.EncryptionOptions; +import org.pgpainless.encryption_signing.EncryptionResult; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; import org.pgpainless.key.info.KeyRingInfo; import org.pgpainless.key.protection.SecretKeyRingProtector; import org.pgpainless.signature.subpackets.SelfSignatureSubpackets; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.NoSuchElementException; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -55,7 +70,7 @@ public class FixUserIdDoesNotBreakEncryptionCapabilityTest { private static final String userIdAfter = "\"(B)ob (J)ohnson\" "; @Test - public void replaceUserIdWithFixedVersionDoesNotHinderEncryptionCapability() throws IOException, PGPException { + public void manualReplaceUserIdWithFixedVersionDoesNotHinderEncryptionCapability() throws IOException, PGPException { PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(SECRET_KEY); SecretKeyRingProtector protector = SecretKeyRingProtector.unprotectedKeys(); PGPSecretKeyRing modified = PGPainless.modifyKeyRing(secretKeys) @@ -81,4 +96,67 @@ public class FixUserIdDoesNotBreakEncryptionCapabilityTest { assertFalse(after.isUserIdValid(userIdBefore)); assertTrue(after.isUserIdValid(userIdAfter)); } + + @Test + public void testReplaceUserId_missingOldUserIdThrows() throws IOException { + PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(SECRET_KEY); + assertThrows(NoSuchElementException.class, () -> PGPainless.modifyKeyRing(secretKeys) + .replaceUserId("missing", userIdAfter, SecretKeyRingProtector.unprotectedKeys())); + } + + @Test + public void testReplaceUserId_emptyOldUserIdThrows() throws IOException { + PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(SECRET_KEY); + assertThrows(IllegalArgumentException.class, () -> PGPainless.modifyKeyRing(secretKeys) + .replaceUserId(" ", userIdAfter, SecretKeyRingProtector.unprotectedKeys())); + } + + @Test + public void testReplaceUserId_emptyNewUserIdThrows() throws IOException { + PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(SECRET_KEY); + assertThrows(IllegalArgumentException.class, () -> PGPainless.modifyKeyRing(secretKeys) + .replaceUserId(userIdBefore, " ", SecretKeyRingProtector.unprotectedKeys())); + } + + @Test + public void testReplaceImplicitUserIdDoesNotBreakStuff() throws IOException, PGPException { + PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(SECRET_KEY); + + PGPSecretKeyRing edited = PGPainless.modifyKeyRing(secretKeys) + .replaceUserId(userIdBefore, userIdAfter, SecretKeyRingProtector.unprotectedKeys()) + .done(); + + KeyRingInfo info = PGPainless.inspectKeyRing(edited); + assertTrue(info.isUserIdValid(userIdAfter)); + assertEquals(userIdAfter, info.getPrimaryUserId()); + + assertTrue(info.getLatestUserIdCertification(userIdAfter).getHashedSubPackets().isPrimaryUserID()); + + PGPPublicKeyRing cert = PGPainless.extractCertificate(edited); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(out) + .withOptions(ProducerOptions.encrypt(new EncryptionOptions() + .addRecipient(cert))); + + encryptionStream.write("Hello".getBytes(StandardCharsets.UTF_8)); + encryptionStream.close(); + + EncryptionResult result = encryptionStream.getResult(); + assertTrue(result.isEncryptedFor(cert)); + + ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray()); + ByteArrayOutputStream plain = new ByteArrayOutputStream(); + DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(in) + .withOptions(new ConsumerOptions() + .addDecryptionKey(edited)); + + Streams.pipeAll(decryptionStream, plain); + decryptionStream.close(); + + OpenPgpMetadata metadata = decryptionStream.getResult(); + assertTrue(metadata.isEncrypted()); + } }