Enable GpgSigner to also sign tags

Factor out a common ObjectBuilder as super class of CommitBuilder
and TagBuilder, and make the GpgSigner work on ObjectBuilder.

In order not to break API, add the new method for signing an
ObjectBuilder in a new interface GpgObjectSigner.

The signature for a tag is just tacked onto the end of the tag
message. The message of a signed tag must end in LF.

Bug: 386908
Change-Id: I5e021e3c927f4051825cd7355b129113b949455e
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java
index ea159c5..449c4a4 100644
--- a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java
+++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java
@@ -38,6 +38,8 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.GpgSignature;
 import org.eclipse.jgit.lib.GpgSigner;
+import org.eclipse.jgit.lib.GpgObjectSigner;
+import org.eclipse.jgit.lib.ObjectBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.util.StringUtils;
@@ -45,7 +47,8 @@
 /**
  * GPG Signer using BouncyCastle library
  */
-public class BouncyCastleGpgSigner extends GpgSigner {
+public class BouncyCastleGpgSigner extends GpgSigner
+		implements GpgObjectSigner {
 
 	private static void registerBouncyCastleProviderIfNecessary() {
 		if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
@@ -98,6 +101,13 @@
 	public void sign(@NonNull CommitBuilder commit,
 			@Nullable String gpgSigningKey, @NonNull PersonIdent committer,
 			CredentialsProvider credentialsProvider) throws CanceledException {
+		signObject(commit, gpgSigningKey, committer, credentialsProvider);
+	}
+
+	@Override
+	public void signObject(@NonNull ObjectBuilder object,
+			@Nullable String gpgSigningKey, @NonNull PersonIdent committer,
+			CredentialsProvider credentialsProvider) throws CanceledException {
 		try (BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt = new BouncyCastleGpgKeyPassphrasePrompt(
 				credentialsProvider)) {
 			BouncyCastleGpgKey gpgKey = locateSigningKey(gpgSigningKey,
@@ -158,10 +168,10 @@
 			ByteArrayOutputStream buffer = new ByteArrayOutputStream();
 			try (BCPGOutputStream out = new BCPGOutputStream(
 					new ArmoredOutputStream(buffer))) {
-				signatureGenerator.update(commit.build());
+				signatureGenerator.update(object.build());
 				signatureGenerator.generate().encode(out);
 			}
-			commit.setGpgSignature(new GpgSignature(buffer.toByteArray()));
+			object.setGpgSignature(new GpgSignature(buffer.toByteArray()));
 		} catch (PGPException | IOException | NoSuchAlgorithmException
 				| NoSuchProviderException | URISyntaxException e) {
 			throw new JGitInternalException(e.getMessage(), e);
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/CommitBuilderTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/CommitBuilderTest.java
index dee58f9..2f1bada 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/CommitBuilderTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/CommitBuilderTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018, Salesforce. and others
+ * Copyright (C) 2018, 2020 Salesforce. and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -53,7 +53,7 @@
 	private void assertGpgSignatureStringOutcome(String signature,
 			String expectedOutcome) throws IOException {
 		ByteArrayOutputStream out = new ByteArrayOutputStream();
-		CommitBuilder.writeGpgSignatureString(signature, out);
+		ObjectBuilder.writeMultiLineHeader(signature, out, true);
 		String formatted_signature = new String(out.toByteArray(), US_ASCII);
 		assertEquals(expectedOutcome, formatted_signature);
 	}
@@ -85,8 +85,8 @@
 		String signature = "Ü Ä";
 		IllegalArgumentException e = assertThrows(
 				IllegalArgumentException.class,
-				() -> CommitBuilder.writeGpgSignatureString(signature,
-						new ByteArrayOutputStream()));
+				() -> ObjectBuilder.writeMultiLineHeader(signature,
+						new ByteArrayOutputStream(), true));
 		String message = MessageFormat.format(JGitText.get().notASCIIString,
 				signature);
 		assertEquals(message, e.getMessage());
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/TagBuilderTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/TagBuilderTest.java
new file mode 100644
index 0000000..5786022
--- /dev/null
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/lib/TagBuilderTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.lib;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+public class TagBuilderTest {
+
+	// @formatter:off
+	private static final String SIGNATURE = "-----BEGIN PGP SIGNATURE-----\n" +
+			"Version: BCPG v1.60\n" +
+			"\n" +
+			"iQEcBAABCAAGBQJb9cVhAAoJEKX+6Axg/6TZeFsH/0CY0WX/z7U8+7S5giFX4wH4\n" +
+			"opvBwqyt6OX8lgNwTwBGHFNt8LdmDCCmKoq/XwkNi3ARVjLhe3gBcKXNoavvPk2Z\n" +
+			"gIg5ChevGkU4afWCOMLVEYnkCBGw2+86XhrK1P7gTHEk1Rd+Yv1ZRDJBY+fFO7yz\n" +
+			"uSBuF5RpEY2sJiIvp27Gub/rY3B5NTR/feO/z+b9oiP/fMUhpRwG5KuWUsn9NPjw\n" +
+			"3tvbgawYpU/2UnS+xnavMY4t2fjRYjsoxndPLb2MUX8X7vC7FgWLBlmI/rquLZVM\n" +
+			"IQEKkjnA+lhejjK1rv+ulq4kGZJFKGYWYYhRDwFg5PTkzhudhN2SGUq5Wxq1Eg4=\n" +
+			"=b9OI\n" +
+			"-----END PGP SIGNATURE-----";
+
+	// @formatter:on
+
+	private static final String TAGGER_LINE = "A U. Thor <a_u_thor@example.com> 1218123387 +0700";
+
+	private static final PersonIdent TAGGER = RawParseUtils
+			.parsePersonIdent(TAGGER_LINE);
+
+	@Test
+	public void testTagSimple() throws Exception {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setEncoding(US_ASCII);
+		t.setMessage("Short message only");
+		t.setTagger(TAGGER);
+		String tag = new String(t.build(), UTF_8);
+		String expected = "object 0000000000000000000000000000000000000000\n"
+				+ "type commit\n" //
+				+ "tag sometag\n" //
+				+ "tagger " + TAGGER_LINE + '\n' //
+				+ "encoding US-ASCII\n" //
+				+ '\n' //
+				+ "Short message only";
+		assertEquals(expected, tag);
+	}
+
+	@Test
+	public void testTagWithSignatureShortMessageEndsInLF() throws Exception {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setEncoding(US_ASCII);
+		t.setMessage("Short message only\n");
+		t.setTagger(TAGGER);
+		t.setGpgSignature(new GpgSignature(SIGNATURE.getBytes(US_ASCII)));
+		String tag = new String(t.build(), UTF_8);
+		String expected = "object 0000000000000000000000000000000000000000\n"
+				+ "type commit\n" //
+				+ "tag sometag\n" //
+				+ "tagger " + TAGGER_LINE + '\n' //
+				+ "encoding US-ASCII\n" //
+				+ '\n' //
+				+ "Short message only\n" //
+				+ SIGNATURE + '\n';
+		assertEquals(expected, tag);
+	}
+
+	@Test
+	public void testTagWithSignatureMessageNoLF() {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setEncoding(US_ASCII);
+		t.setMessage("A message\n\nthat does not end in LF");
+		t.setTagger(TAGGER);
+		t.setGpgSignature(new GpgSignature(SIGNATURE.getBytes(US_ASCII)));
+		Throwable ex = assertThrows(Throwable.class, t::build);
+		assertEquals(JGitText.get().signedTagMessageNoLf, ex.getMessage());
+	}
+
+	@Test
+	public void testTagWithSignatureNoParagraphsMessage() throws Exception {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setEncoding(US_ASCII);
+		t.setMessage("A strange\ntag message\n");
+		t.setTagger(TAGGER);
+		t.setGpgSignature(new GpgSignature(SIGNATURE.getBytes(US_ASCII)));
+		String tag = new String(t.build(), UTF_8);
+		String expected = "object 0000000000000000000000000000000000000000\n"
+				+ "type commit\n" //
+				+ "tag sometag\n" //
+				+ "tagger " + TAGGER_LINE + '\n' //
+				+ "encoding US-ASCII\n" //
+				+ '\n' //
+				+ "A strange\ntag message\n" //
+				+ SIGNATURE + '\n';
+		assertEquals(expected, tag);
+	}
+
+	@Test
+	public void testTagWithSignatureLongMessage() throws Exception {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setMessage("Short message\n\nFollowed by explanations.\n");
+		t.setTagger(TAGGER);
+		t.setGpgSignature(new GpgSignature(SIGNATURE.getBytes(US_ASCII)));
+		String tag = new String(t.build(), UTF_8);
+		String expected = "object 0000000000000000000000000000000000000000\n"
+				+ "type commit\n" //
+				+ "tag sometag\n" //
+				+ "tagger " + TAGGER_LINE + '\n' //
+				+ '\n' //
+				+ "Short message\n\nFollowed by explanations.\n" //
+				+ SIGNATURE + '\n';
+		assertEquals(expected, tag);
+	}
+
+	@Test
+	public void testTagWithSignatureEmptyMessage() throws Exception {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setTagger(TAGGER);
+		t.setMessage("");
+		String emptyMsg = new String(t.build(), UTF_8);
+		t.setGpgSignature(new GpgSignature(SIGNATURE.getBytes(US_ASCII)));
+		String tag = new String(t.build(), UTF_8);
+		String expected = "object 0000000000000000000000000000000000000000\n"
+				+ "type commit\n" //
+				+ "tag sometag\n" //
+				+ "tagger " + TAGGER_LINE + '\n' //
+				+ '\n';
+		assertEquals(expected, emptyMsg);
+		assertEquals(expected + SIGNATURE + '\n', tag);
+	}
+
+	@Test
+	public void testTagWithSignatureOnly() throws Exception {
+		TagBuilder t = new TagBuilder();
+		t.setTag("sometag");
+		t.setObjectId(ObjectId.zeroId(), Constants.OBJ_COMMIT);
+		t.setTagger(TAGGER);
+		String emptyMsg = new String(t.build(), UTF_8);
+		t.setGpgSignature(new GpgSignature(SIGNATURE.getBytes(US_ASCII)));
+		String tag = new String(t.build(), UTF_8);
+		String expected = "object 0000000000000000000000000000000000000000\n"
+				+ "type commit\n" //
+				+ "tag sometag\n" //
+				+ "tagger " + TAGGER_LINE + '\n' //
+				+ '\n';
+		assertEquals(expected, emptyMsg);
+		assertEquals(expected + SIGNATURE + '\n', tag);
+	}
+
+}
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevTagParseTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevTagParseTest.java
index b92a072..edddc33 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevTagParseTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/RevTagParseTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2008-2010, Google Inc. and others
+ * Copyright (C) 2008, 2020, Google Inc. and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -11,6 +11,7 @@
 package org.eclipse.jgit.revwalk;
 
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.US_ASCII;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
@@ -18,6 +19,7 @@
 import static org.junit.Assert.assertSame;
 
 import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
 
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.junit.RepositoryTestCase;
@@ -117,6 +119,7 @@
 		assertNotNull(c.getTagName());
 		assertEquals(name, c.getTagName());
 		assertEquals("", c.getFullMessage());
+		assertNull(c.getRawGpgSignature());
 
 		final PersonIdent cTagger = c.getTaggerIdent();
 		assertNotNull(cTagger);
@@ -128,13 +131,12 @@
 	public void testParseOldStyleNoTagger() throws Exception {
 		final ObjectId treeId = id("9788669ad918b6fcce64af8882fc9a81cb6aba67");
 		final String name = "v1.2.3.4.5";
-		final String message = "test\n" //
-				+ "\n" //
-				+ "-----BEGIN PGP SIGNATURE-----\n" //
+		final String fakeSignature = "-----BEGIN PGP SIGNATURE-----\n" //
 				+ "Version: GnuPG v1.4.1 (GNU/Linux)\n" //
 				+ "\n" //
 				+ "iD8DBQBC0b9oF3Y\n" //
-				+ "-----END PGP SIGNATURE------n";
+				+ "-----END PGP SIGNATURE-----";
+		final String message = "test\n\n" + fakeSignature + '\n';
 
 		final StringBuilder body = new StringBuilder();
 
@@ -167,6 +169,8 @@
 		assertEquals(name, c.getTagName());
 		assertEquals("test", c.getShortMessage());
 		assertEquals(message, c.getFullMessage());
+		assertEquals(fakeSignature + '\n',
+				new String(c.getRawGpgSignature(), US_ASCII));
 
 		assertNull(c.getTaggerIdent());
 	}
@@ -386,6 +390,108 @@
 	}
 
 	@Test
+	public void testParse_gpgSignature() throws Exception {
+		final String signature = "-----BEGIN PGP SIGNATURE-----\n\n"
+				+ "wsBcBAABCAAQBQJbGB4pCRBK7hj4Ov3rIwAAdHIIAENrvz23867ZgqrmyPemBEZP\n"
+				+ "U24B1Tlq/DWvce2buaxmbNQngKZ0pv2s8VMc11916WfTIC9EKvioatmpjduWvhqj\n"
+				+ "znQTFyiMor30pyYsfrqFuQZvqBW01o8GEWqLg8zjf9Rf0R3LlOEw86aT8CdHRlm6\n"
+				+ "wlb22xb8qoX4RB+LYfz7MhK5F+yLOPXZdJnAVbuyoMGRnDpwdzjL5Hj671+XJxN5\n"
+				+ "SasRdhxkkfw/ZnHxaKEc4juMz8Nziz27elRwhOQqlTYoXNJnsV//wy5Losd7aKi1\n"
+				+ "xXXyUpndEOmT0CIcKHrN/kbYoVL28OJaxoBuva3WYQaRrzEe3X02NMxZe9gkSqA=\n"
+				+ "=TClh\n" + "-----END PGP SIGNATURE-----";
+		ByteArrayOutputStream b = new ByteArrayOutputStream();
+		b.write("object 9788669ad918b6fcce64af8882fc9a81cb6aba67\n"
+				.getBytes(UTF_8));
+		b.write("type tree\n".getBytes(UTF_8));
+		b.write("tag v1.0\n".getBytes(UTF_8));
+		b.write("tagger t <t@example.com> 1218123387 +0700\n".getBytes(UTF_8));
+		b.write('\n');
+		b.write("message\n\n".getBytes(UTF_8));
+		b.write(signature.getBytes(US_ASCII));
+		b.write('\n');
+
+		RevTag t = new RevTag(id("9473095c4cb2f12aefe1db8a355fe3fafba42f67"));
+		try (RevWalk rw = new RevWalk(db)) {
+			t.parseCanonical(rw, b.toByteArray());
+		}
+
+		assertEquals("t", t.getTaggerIdent().getName());
+		assertEquals("message", t.getShortMessage());
+		assertEquals("message\n\n" + signature + '\n', t.getFullMessage());
+		String gpgSig = new String(t.getRawGpgSignature(), UTF_8);
+		assertEquals(signature + '\n', gpgSig);
+	}
+
+	@Test
+	public void testParse_gpgSignature2() throws Exception {
+		final String signature = "-----BEGIN PGP SIGNATURE-----\n\n"
+				+ "wsBcBAABCAAQBQJbGB4pCRBK7hj4Ov3rIwAAdHIIAENrvz23867ZgqrmyPemBEZP\n"
+				+ "U24B1Tlq/DWvce2buaxmbNQngKZ0pv2s8VMc11916WfTIC9EKvioatmpjduWvhqj\n"
+				+ "znQTFyiMor30pyYsfrqFuQZvqBW01o8GEWqLg8zjf9Rf0R3LlOEw86aT8CdHRlm6\n"
+				+ "wlb22xb8qoX4RB+LYfz7MhK5F+yLOPXZdJnAVbuyoMGRnDpwdzjL5Hj671+XJxN5\n"
+				+ "SasRdhxkkfw/ZnHxaKEc4juMz8Nziz27elRwhOQqlTYoXNJnsV//wy5Losd7aKi1\n"
+				+ "xXXyUpndEOmT0CIcKHrN/kbYoVL28OJaxoBuva3WYQaRrzEe3X02NMxZe9gkSqA=\n"
+				+ "=TClh\n" + "-----END PGP SIGNATURE-----";
+		ByteArrayOutputStream b = new ByteArrayOutputStream();
+		b.write("object 9788669ad918b6fcce64af8882fc9a81cb6aba67\n"
+				.getBytes(UTF_8));
+		b.write("type tree\n".getBytes(UTF_8));
+		b.write("tag v1.0\n".getBytes(UTF_8));
+		b.write("tagger t <t@example.com> 1218123387 +0700\n".getBytes(UTF_8));
+		b.write('\n');
+		String message = "message\n\n" + signature.replace("xXXy", "aAAb")
+				+ '\n';
+		b.write(message.getBytes(UTF_8));
+		b.write(signature.getBytes(US_ASCII));
+		b.write('\n');
+
+		RevTag t = new RevTag(id("9473095c4cb2f12aefe1db8a355fe3fafba42f67"));
+		try (RevWalk rw = new RevWalk(db)) {
+			t.parseCanonical(rw, b.toByteArray());
+		}
+
+		assertEquals("t", t.getTaggerIdent().getName());
+		assertEquals("message", t.getShortMessage());
+		assertEquals(message + signature + '\n', t.getFullMessage());
+		String gpgSig = new String(t.getRawGpgSignature(), UTF_8);
+		assertEquals(signature + '\n', gpgSig);
+	}
+
+	@Test
+	public void testParse_gpgSignature3() throws Exception {
+		final String signature = "-----BEGIN PGP SIGNATURE-----\n\n"
+				+ "wsBcBAABCAAQBQJbGB4pCRBK7hj4Ov3rIwAAdHIIAENrvz23867ZgqrmyPemBEZP\n"
+				+ "U24B1Tlq/DWvce2buaxmbNQngKZ0pv2s8VMc11916WfTIC9EKvioatmpjduWvhqj\n"
+				+ "znQTFyiMor30pyYsfrqFuQZvqBW01o8GEWqLg8zjf9Rf0R3LlOEw86aT8CdHRlm6\n"
+				+ "wlb22xb8qoX4RB+LYfz7MhK5F+yLOPXZdJnAVbuyoMGRnDpwdzjL5Hj671+XJxN5\n"
+				+ "SasRdhxkkfw/ZnHxaKEc4juMz8Nziz27elRwhOQqlTYoXNJnsV//wy5Losd7aKi1\n"
+				+ "xXXyUpndEOmT0CIcKHrN/kbYoVL28OJaxoBuva3WYQaRrzEe3X02NMxZe9gkSqA=\n"
+				+ "=TClh\n" + "-----END PGP SIGNATURE-----";
+		ByteArrayOutputStream b = new ByteArrayOutputStream();
+		b.write("object 9788669ad918b6fcce64af8882fc9a81cb6aba67\n"
+				.getBytes(UTF_8));
+		b.write("type tree\n".getBytes(UTF_8));
+		b.write("tag v1.0\n".getBytes(UTF_8));
+		b.write("tagger t <t@example.com> 1218123387 +0700\n".getBytes(UTF_8));
+		b.write('\n');
+		String message = "message\n\n-----BEGIN PGP SIGNATURE-----\n";
+		b.write(message.getBytes(UTF_8));
+		b.write(signature.getBytes(US_ASCII));
+		b.write('\n');
+
+		RevTag t = new RevTag(id("9473095c4cb2f12aefe1db8a355fe3fafba42f67"));
+		try (RevWalk rw = new RevWalk(db)) {
+			t.parseCanonical(rw, b.toByteArray());
+		}
+
+		assertEquals("t", t.getTaggerIdent().getName());
+		assertEquals("message", t.getShortMessage());
+		assertEquals(message + signature + '\n', t.getFullMessage());
+		String gpgSig = new String(t.getRawGpgSignature(), UTF_8);
+		assertEquals(signature + '\n', gpgSig);
+	}
+
+	@Test
 	public void testParse_NoMessage() throws Exception {
 		final String msg = "";
 		final RevTag c = create(msg);
@@ -447,7 +553,8 @@
 	}
 
 	@Test
-	public void testParse_PublicParseMethod() throws CorruptObjectException {
+	public void testParse_PublicParseMethod()
+			throws CorruptObjectException, UnsupportedEncodingException {
 		TagBuilder src = new TagBuilder();
 		try (ObjectInserter.Formatter fmt = new ObjectInserter.Formatter()) {
 			src.setObjectId(fmt.idFor(Constants.OBJ_TREE, new byte[] {}),
diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
index 12902b9..6d15464 100644
--- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
+++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
@@ -617,6 +617,7 @@
 shortReadOfBlock=Short read of block.
 shortReadOfOptionalDIRCExtensionExpectedAnotherBytes=Short read of optional DIRC extension {0}; expected another {1} bytes within the section.
 shortSkipOfBlock=Short skip of block.
+signedTagMessageNoLf=A non-empty message of a signed tag must end in LF.
 signingNotSupportedOnTag=Signing isn't supported on tag operations yet.
 signingServiceUnavailable=Signing service is not available
 similarityScoreMustBeWithinBounds=Similarity score must be between 0 and 100.
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
index 892657d..a7daed1 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
@@ -645,6 +645,7 @@
 	/***/ public String shortReadOfBlock;
 	/***/ public String shortReadOfOptionalDIRCExtensionExpectedAnotherBytes;
 	/***/ public String shortSkipOfBlock;
+	/***/ public String signedTagMessageNoLf;
 	/***/ public String signingNotSupportedOnTag;
 	/***/ public String signingServiceUnavailable;
 	/***/ public String similarityScoreMustBeWithinBounds;
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/CommitBuilder.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/CommitBuilder.java
index 4f93fda..1665f05 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/lib/CommitBuilder.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/CommitBuilder.java
@@ -1,7 +1,7 @@
 /*
  * Copyright (C) 2007, Dave Watson <dwatson@mimvista.com>
- * Copyright (C) 2006-2007, Robin Rosenberg <robin.rosenberg@dewire.com>
- * Copyright (C) 2006-2007, Shawn O. Pearce <spearce@spearce.org> and others
+ * Copyright (C) 2006, 2007, Robin Rosenberg <robin.rosenberg@dewire.com>
+ * Copyright (C) 2006, 2020, Shawn O. Pearce <spearce@spearce.org> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -16,14 +16,11 @@
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.UnsupportedEncodingException;
 import java.nio.charset.Charset;
-import java.text.MessageFormat;
 import java.util.List;
 
-import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.util.References;
 
 /**
@@ -37,7 +34,7 @@
  * and obtain a {@link org.eclipse.jgit.revwalk.RevCommit} instance by calling
  * {@link org.eclipse.jgit.revwalk.RevWalk#parseCommit(AnyObjectId)}.
  */
-public class CommitBuilder {
+public class CommitBuilder extends ObjectBuilder {
 	private static final ObjectId[] EMPTY_OBJECTID_LIST = new ObjectId[0];
 
 	private static final byte[] htree = Constants.encodeASCII("tree"); //$NON-NLS-1$
@@ -50,28 +47,17 @@
 
 	private static final byte[] hgpgsig = Constants.encodeASCII("gpgsig"); //$NON-NLS-1$
 
-	private static final byte[] hencoding = Constants.encodeASCII("encoding"); //$NON-NLS-1$
-
 	private ObjectId treeId;
 
 	private ObjectId[] parentIds;
 
-	private PersonIdent author;
-
 	private PersonIdent committer;
 
-	private GpgSignature gpgSignature;
-
-	private String message;
-
-	private Charset encoding;
-
 	/**
 	 * Initialize an empty commit.
 	 */
 	public CommitBuilder() {
 		parentIds = EMPTY_OBJECTID_LIST;
-		encoding = UTF_8;
 	}
 
 	/**
@@ -98,8 +84,9 @@
 	 *
 	 * @return the author of this commit (who wrote it).
 	 */
+	@Override
 	public PersonIdent getAuthor() {
-		return author;
+		return super.getAuthor();
 	}
 
 	/**
@@ -108,8 +95,9 @@
 	 * @param newAuthor
 	 *            the new author. Should not be null.
 	 */
+	@Override
 	public void setAuthor(PersonIdent newAuthor) {
-		author = newAuthor;
+		super.setAuthor(newAuthor);
 	}
 
 	/**
@@ -132,38 +120,6 @@
 	}
 
 	/**
-	 * Set the GPG signature of this commit.
-	 * <p>
-	 * Note, the signature set here will change the payload of the commit, i.e.
-	 * the output of {@link #build()} will include the signature. Thus, the
-	 * typical flow will be:
-	 * <ol>
-	 * <li>call {@link #build()} without a signature set to obtain payload</li>
-	 * <li>create {@link GpgSignature} from payload</li>
-	 * <li>set {@link GpgSignature}</li>
-	 * </ol>
-	 * </p>
-	 *
-	 * @param newSignature
-	 *            the signature to set or <code>null</code> to unset
-	 * @since 5.3
-	 */
-	public void setGpgSignature(GpgSignature newSignature) {
-		gpgSignature = newSignature;
-	}
-
-	/**
-	 * Get the GPG signature of this commit.
-	 *
-	 * @return the GPG signature of this commit, maybe <code>null</code> if the
-	 *         commit is not to be signed
-	 * @since 5.3
-	 */
-	public GpgSignature getGpgSignature() {
-		return gpgSignature;
-	}
-
-	/**
 	 * Get the ancestors of this commit.
 	 *
 	 * @return the ancestors of this commit. Never null.
@@ -239,25 +195,6 @@
 	}
 
 	/**
-	 * Get the complete commit message.
-	 *
-	 * @return the complete commit message.
-	 */
-	public String getMessage() {
-		return message;
-	}
-
-	/**
-	 * Set the commit message.
-	 *
-	 * @param newMessage
-	 *            the commit message. Should not be null.
-	 */
-	public void setMessage(String newMessage) {
-		message = newMessage;
-	}
-
-	/**
 	 * Set the encoding for the commit information.
 	 *
 	 * @param encodingName
@@ -267,37 +204,10 @@
 	 */
 	@Deprecated
 	public void setEncoding(String encodingName) {
-		encoding = Charset.forName(encodingName);
+		setEncoding(Charset.forName(encodingName));
 	}
 
-	/**
-	 * Set the encoding for the commit information.
-	 *
-	 * @param enc
-	 *            the encoding to use.
-	 */
-	public void setEncoding(Charset enc) {
-		encoding = enc;
-	}
-
-	/**
-	 * Get the encoding that should be used for the commit message text.
-	 *
-	 * @return the encoding that should be used for the commit message text.
-	 */
-	public Charset getEncoding() {
-		return encoding;
-	}
-
-	/**
-	 * Format this builder's state as a commit object.
-	 *
-	 * @return this object in the canonical commit format, suitable for storage
-	 *         in a repository.
-	 * @throws java.io.UnsupportedEncodingException
-	 *             the encoding specified by {@link #getEncoding()} is not
-	 *             supported by this Java runtime.
-	 */
+	@Override
 	public byte[] build() throws UnsupportedEncodingException {
 		ByteArrayOutputStream os = new ByteArrayOutputStream();
 		OutputStreamWriter w = new OutputStreamWriter(os, getEncoding());
@@ -326,19 +236,16 @@
 			w.flush();
 			os.write('\n');
 
-			if (getGpgSignature() != null) {
+			GpgSignature signature = getGpgSignature();
+			if (signature != null) {
 				os.write(hgpgsig);
 				os.write(' ');
-				writeGpgSignatureString(getGpgSignature().toExternalString(), os);
+				writeMultiLineHeader(signature.toExternalString(), os,
+						true);
 				os.write('\n');
 			}
 
-			if (!References.isSameObject(getEncoding(), UTF_8)) {
-				os.write(hencoding);
-				os.write(' ');
-				os.write(Constants.encodeASCII(getEncoding().name()));
-				os.write('\n');
-			}
+			writeEncoding(getEncoding(), os);
 
 			os.write('\n');
 
@@ -356,58 +263,6 @@
 	}
 
 	/**
-	 * Writes signature to output as per <a href=
-	 * "https://github.com/git/git/blob/master/Documentation/technical/signature-format.txt#L66,L89">gpgsig
-	 * header</a>.
-	 * <p>
-	 * CRLF and CR will be sanitized to LF and signature will have a hanging
-	 * indent of one space starting with line two. A trailing line break is
-	 * <em>not</em> written; the caller is supposed to terminate the GPG
-	 * signature header by writing a single newline.
-	 * </p>
-	 *
-	 * @param in
-	 *            signature string with line breaks
-	 * @param out
-	 *            output stream
-	 * @throws IOException
-	 *             thrown by the output stream
-	 * @throws IllegalArgumentException
-	 *             if the signature string contains non 7-bit ASCII chars
-	 */
-	static void writeGpgSignatureString(String in, OutputStream out)
-			throws IOException, IllegalArgumentException {
-		int length = in.length();
-		for (int i = 0; i < length; ++i) {
-			char ch = in.charAt(i);
-			switch (ch) {
-			case '\r':
-				if (i + 1 < length && in.charAt(i + 1) == '\n') {
-					++i;
-				}
-				if (i + 1 < length) {
-					out.write('\n');
-					out.write(' ');
-				}
-				break;
-			case '\n':
-				if (i + 1 < length) {
-					out.write('\n');
-					out.write(' ');
-				}
-				break;
-			default:
-				// sanity check
-				if (ch > 127)
-					throw new IllegalArgumentException(MessageFormat
-							.format(JGitText.get().notASCIIString, in));
-				out.write(ch);
-				break;
-			}
-		}
-	}
-
-	/**
 	 * Format this builder's state as a commit object.
 	 *
 	 * @return this object in the canonical commit format, suitable for storage
@@ -439,7 +294,7 @@
 		}
 
 		r.append("author ");
-		r.append(author != null ? author.toString() : "NOT_SET");
+		r.append(getAuthor() != null ? getAuthor().toString() : "NOT_SET");
 		r.append("\n");
 
 		r.append("committer ");
@@ -447,17 +302,20 @@
 		r.append("\n");
 
 		r.append("gpgSignature ");
-		r.append(gpgSignature != null ? gpgSignature.toString() : "NOT_SET");
+		GpgSignature signature = getGpgSignature();
+		r.append(signature != null ? signature.toString()
+				: "NOT_SET");
 		r.append("\n");
 
-		if (encoding != null && !References.isSameObject(encoding, UTF_8)) {
+		Charset encoding = getEncoding();
+		if (!References.isSameObject(encoding, UTF_8)) {
 			r.append("encoding ");
 			r.append(encoding.name());
 			r.append("\n");
 		}
 
 		r.append("\n");
-		r.append(message != null ? message : "");
+		r.append(getMessage() != null ? getMessage() : "");
 		r.append("}");
 		return r.toString();
 	}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgObjectSigner.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgObjectSigner.java
new file mode 100644
index 0000000..6fb7677
--- /dev/null
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgObjectSigner.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.lib;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.api.errors.CanceledException;
+import org.eclipse.jgit.transport.CredentialsProvider;
+
+/**
+ * Creates GPG signatures for Git objects.
+ *
+ * @since 5.11
+ */
+public interface GpgObjectSigner {
+
+	/**
+	 * Signs the specified object.
+	 *
+	 * <p>
+	 * Implementors should obtain the payload for signing from the specified
+	 * object via {@link ObjectBuilder#build()} and create a proper
+	 * {@link GpgSignature}. The generated signature must be set on the
+	 * specified {@code object} (see
+	 * {@link ObjectBuilder#setGpgSignature(GpgSignature)}).
+	 * </p>
+	 * <p>
+	 * Any existing signature on the object must be discarded prior obtaining
+	 * the payload via {@link ObjectBuilder#build()}.
+	 * </p>
+	 *
+	 * @param object
+	 *            the object to sign (must not be {@code null} and must be
+	 *            complete to allow proper calculation of payload)
+	 * @param gpgSigningKey
+	 *            the signing key to locate (passed as is to the GPG signing
+	 *            tool as is; eg., value of <code>user.signingkey</code>)
+	 * @param committer
+	 *            the signing identity (to help with key lookup in case signing
+	 *            key is not specified)
+	 * @param credentialsProvider
+	 *            provider to use when querying for signing key credentials (eg.
+	 *            passphrase)
+	 * @throws CanceledException
+	 *             when signing was canceled (eg., user aborted when entering
+	 *             passphrase)
+	 */
+	void signObject(@NonNull ObjectBuilder object,
+			@Nullable String gpgSigningKey, @NonNull PersonIdent committer,
+			CredentialsProvider credentialsProvider) throws CanceledException;
+
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/ObjectBuilder.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/ObjectBuilder.java
new file mode 100644
index 0000000..4b7054f
--- /dev/null
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/ObjectBuilder.java
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2020, Thomas Wolf <thomas.wolf@paranor.ch> and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.lib;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.text.MessageFormat;
+import java.util.Objects;
+
+import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.util.References;
+
+/**
+ * Common base class for {@link CommitBuilder} and {@link TagBuilder}.
+ *
+ * @since 5.11
+ */
+public abstract class ObjectBuilder {
+
+	/** Byte representation of "encoding". */
+	private static final byte[] hencoding = Constants.encodeASCII("encoding"); //$NON-NLS-1$
+
+	private PersonIdent author;
+
+	private GpgSignature gpgSignature;
+
+	private String message;
+
+	private Charset encoding = StandardCharsets.UTF_8;
+
+	/**
+	 * Retrieves the author of this object.
+	 *
+	 * @return the author of this object, or {@code null} if not set yet
+	 */
+	protected PersonIdent getAuthor() {
+		return author;
+	}
+
+	/**
+	 * Sets the author (name, email address, and date) of this object.
+	 *
+	 * @param newAuthor
+	 *            the new author, must be non-{@code null}
+	 */
+	protected void setAuthor(PersonIdent newAuthor) {
+		author = Objects.requireNonNull(newAuthor);
+	}
+
+	/**
+	 * Sets the GPG signature of this object.
+	 * <p>
+	 * Note, the signature set here will change the payload of the object, i.e.
+	 * the output of {@link #build()} will include the signature. Thus, the
+	 * typical flow will be:
+	 * <ol>
+	 * <li>call {@link #build()} without a signature set to obtain payload</li>
+	 * <li>create {@link GpgSignature} from payload</li>
+	 * <li>set {@link GpgSignature}</li>
+	 * </ol>
+	 * </p>
+	 *
+	 * @param gpgSignature
+	 *            the signature to set or {@code null} to unset
+	 * @since 5.3
+	 */
+	public void setGpgSignature(@Nullable GpgSignature gpgSignature) {
+		this.gpgSignature = gpgSignature;
+	}
+
+	/**
+	 * Retrieves the GPG signature of this object.
+	 *
+	 * @return the GPG signature of this object, or {@code null} if the object
+	 *         is not signed
+	 * @since 5.3
+	 */
+	@Nullable
+	public GpgSignature getGpgSignature() {
+		return gpgSignature;
+	}
+
+	/**
+	 * Retrieves the complete message of the object.
+	 *
+	 * @return the complete message; can be {@code null}.
+	 */
+	@Nullable
+	public String getMessage() {
+		return message;
+	}
+
+	/**
+	 * Sets the message (commit message, or message of an annotated tag).
+	 *
+	 * @param message
+	 *            the message.
+	 */
+	public void setMessage(@Nullable String message) {
+		this.message = message;
+	}
+
+	/**
+	 * Retrieves the encoding that should be used for the message text.
+	 *
+	 * @return the encoding that should be used for the message text.
+	 */
+	@NonNull
+	public Charset getEncoding() {
+		return encoding;
+	}
+
+	/**
+	 * Sets the encoding for the object message.
+	 *
+	 * @param encoding
+	 *            the encoding to use.
+	 */
+	public void setEncoding(@NonNull Charset encoding) {
+		this.encoding = encoding;
+	}
+
+	/**
+	 * Format this builder's state as a git object.
+	 *
+	 * @return this object in the canonical git format, suitable for storage in
+	 *         a repository.
+	 * @throws java.io.UnsupportedEncodingException
+	 *             the encoding specified by {@link #getEncoding()} is not
+	 *             supported by this Java runtime.
+	 */
+	@NonNull
+	public abstract byte[] build() throws UnsupportedEncodingException;
+
+	/**
+	 * Writes signature to output as per <a href=
+	 * "https://github.com/git/git/blob/master/Documentation/technical/signature-format.txt#L66,L89">gpgsig
+	 * header</a>.
+	 * <p>
+	 * CRLF and CR will be sanitized to LF and signature will have a hanging
+	 * indent of one space starting with line two. A trailing line break is
+	 * <em>not</em> written; the caller is supposed to terminate the GPG
+	 * signature header by writing a single newline.
+	 * </p>
+	 *
+	 * @param in
+	 *            signature string with line breaks
+	 * @param out
+	 *            output stream
+	 * @param enforceAscii
+	 *            whether to throw {@link IllegalArgumentException} if non-ASCII
+	 *            characters are encountered
+	 * @throws IOException
+	 *             thrown by the output stream
+	 * @throws IllegalArgumentException
+	 *             if the signature string contains non 7-bit ASCII chars and
+	 *             {@code enforceAscii == true}
+	 */
+	static void writeMultiLineHeader(@NonNull String in,
+			@NonNull OutputStream out, boolean enforceAscii)
+			throws IOException, IllegalArgumentException {
+		int length = in.length();
+		for (int i = 0; i < length; ++i) {
+			char ch = in.charAt(i);
+			switch (ch) {
+			case '\r':
+				if (i + 1 < length && in.charAt(i + 1) == '\n') {
+					++i;
+				}
+				if (i + 1 < length) {
+					out.write('\n');
+					out.write(' ');
+				}
+				break;
+			case '\n':
+				if (i + 1 < length) {
+					out.write('\n');
+					out.write(' ');
+				}
+				break;
+			default:
+				// sanity check
+				if (ch > 127 && enforceAscii)
+					throw new IllegalArgumentException(MessageFormat
+							.format(JGitText.get().notASCIIString, in));
+				out.write(ch);
+				break;
+			}
+		}
+	}
+
+	/**
+	 * Writes an "encoding" header.
+	 *
+	 * @param encoding
+	 *            to write
+	 * @param out
+	 *            to write to
+	 * @throws IOException
+	 *             if writing fails
+	 */
+	static void writeEncoding(@NonNull Charset encoding,
+			@NonNull OutputStream out) throws IOException {
+		if (!References.isSameObject(encoding, UTF_8)) {
+			out.write(hencoding);
+			out.write(' ');
+			out.write(Constants.encodeASCII(encoding.name()));
+			out.write('\n');
+		}
+	}
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/TagBuilder.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/TagBuilder.java
index 71f0115..facb4a5 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/lib/TagBuilder.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/TagBuilder.java
@@ -1,7 +1,7 @@
 /*
- * Copyright (C) 2006-2008, Robin Rosenberg <robin.rosenberg@dewire.com>
+ * Copyright (C) 2006, 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
  * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
- * Copyright (C) 2010, Chris Aniszczyk <caniszczyk@gmail.com> and others
+ * Copyright (C) 2010, 2020, Chris Aniszczyk <caniszczyk@gmail.com> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -17,8 +17,13 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStreamWriter;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
 
+import org.eclipse.jgit.api.errors.JGitInternalException;
+import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.util.References;
 
 /**
  * Mutable builder to construct an annotated tag recording a project state.
@@ -30,17 +35,22 @@
  * and obtain a {@link org.eclipse.jgit.revwalk.RevTag} instance by calling
  * {@link org.eclipse.jgit.revwalk.RevWalk#parseTag(AnyObjectId)}.
  */
-public class TagBuilder {
+public class TagBuilder extends ObjectBuilder {
+
+	private static final byte[] hobject = Constants.encodeASCII("object"); //$NON-NLS-1$
+
+	private static final byte[] htype = Constants.encodeASCII("type"); //$NON-NLS-1$
+
+	private static final byte[] htag = Constants.encodeASCII("tag"); //$NON-NLS-1$
+
+	private static final byte[] htagger = Constants.encodeASCII("tagger"); //$NON-NLS-1$
+
 	private ObjectId object;
 
 	private int type = Constants.OBJ_BAD;
 
 	private String tag;
 
-	private PersonIdent tagger;
-
-	private String message;
-
 	/**
 	 * Get the type of object this tag refers to.
 	 *
@@ -109,7 +119,7 @@
 	 * @return creator of this tag. May be null.
 	 */
 	public PersonIdent getTagger() {
-		return tagger;
+		return getAuthor();
 	}
 
 	/**
@@ -119,26 +129,7 @@
 	 *            the creator. May be null.
 	 */
 	public void setTagger(PersonIdent taggerIdent) {
-		tagger = taggerIdent;
-	}
-
-	/**
-	 * Get the complete commit message.
-	 *
-	 * @return the complete commit message.
-	 */
-	public String getMessage() {
-		return message;
-	}
-
-	/**
-	 * Set the tag's message.
-	 *
-	 * @param newMessage
-	 *            the tag's message.
-	 */
-	public void setMessage(String newMessage) {
-		message = newMessage;
+		setAuthor(taggerIdent);
 	}
 
 	/**
@@ -147,31 +138,65 @@
 	 * @return this object in the canonical annotated tag format, suitable for
 	 *         storage in a repository.
 	 */
-	public byte[] build() {
+	@Override
+	public byte[] build() throws UnsupportedEncodingException {
 		ByteArrayOutputStream os = new ByteArrayOutputStream();
 		try (OutputStreamWriter w = new OutputStreamWriter(os,
-				UTF_8)) {
-			w.write("object "); //$NON-NLS-1$
-			getObjectId().copyTo(w);
-			w.write('\n');
+				getEncoding())) {
 
-			w.write("type "); //$NON-NLS-1$
-			w.write(Constants.typeString(getObjectType()));
-			w.write("\n"); //$NON-NLS-1$
+			os.write(hobject);
+			os.write(' ');
+			getObjectId().copyTo(os);
+			os.write('\n');
 
-			w.write("tag "); //$NON-NLS-1$
+			os.write(htype);
+			os.write(' ');
+			os.write(Constants
+					.encodeASCII(Constants.typeString(getObjectType())));
+			os.write('\n');
+
+			os.write(htag);
+			os.write(' ');
 			w.write(getTag());
-			w.write("\n"); //$NON-NLS-1$
+			w.flush();
+			os.write('\n');
 
 			if (getTagger() != null) {
-				w.write("tagger "); //$NON-NLS-1$
+				os.write(htagger);
+				os.write(' ');
 				w.write(getTagger().toExternalString());
-				w.write('\n');
+				w.flush();
+				os.write('\n');
 			}
 
-			w.write('\n');
-			if (getMessage() != null)
-				w.write(getMessage());
+			writeEncoding(getEncoding(), os);
+
+			os.write('\n');
+			String msg = getMessage();
+			if (msg != null) {
+				w.write(msg);
+				w.flush();
+			}
+
+			GpgSignature signature = getGpgSignature();
+			if (signature != null) {
+				if (msg != null && !msg.isEmpty() && !msg.endsWith("\n")) { //$NON-NLS-1$
+					// If signed, the message *must* end with a linefeed
+					// character, otherwise signature verification will fail.
+					// (The signature will have been computed over the payload
+					// containing the message without LF, but will be verified
+					// against a payload with the LF.) The signature must start
+					// on a new line.
+					throw new JGitInternalException(
+							JGitText.get().signedTagMessageNoLf);
+				}
+				String externalForm = signature.toExternalString();
+				w.write(externalForm);
+				w.flush();
+				if (!externalForm.endsWith("\n")) { //$NON-NLS-1$
+					os.write('\n');
+				}
+			}
 		} catch (IOException err) {
 			// This should never occur, the only way to get it above is
 			// for the ByteArrayOutputStream to throw, but it doesn't.
@@ -185,10 +210,17 @@
 	 * Format this builder's state as an annotated tag object.
 	 *
 	 * @return this object in the canonical annotated tag format, suitable for
-	 *         storage in a repository.
+	 *         storage in a repository, or {@code null} if the tag cannot be
+	 *         encoded
+	 * @deprecated since 5.11; use {@link #build()} instead
 	 */
+	@Deprecated
 	public byte[] toByteArray() {
-		return build();
+		try {
+			return build();
+		} catch (UnsupportedEncodingException e) {
+			return null;
+		}
 	}
 
 	/** {@inheritDoc} */
@@ -211,14 +243,23 @@
 		r.append(tag != null ? tag : "NOT_SET");
 		r.append("\n");
 
-		if (tagger != null) {
+		if (getTagger() != null) {
 			r.append("tagger ");
-			r.append(tagger);
+			r.append(getTagger());
+			r.append("\n");
+		}
+
+		Charset encoding = getEncoding();
+		if (!References.isSameObject(encoding, UTF_8)) {
+			r.append("encoding ");
+			r.append(encoding.name());
 			r.append("\n");
 		}
 
 		r.append("\n");
-		r.append(message != null ? message : "");
+		r.append(getMessage() != null ? getMessage() : "");
+		GpgSignature signature = getGpgSignature();
+		r.append(signature != null ? signature.toExternalString() : "");
 		r.append("}");
 		return r.toString();
 	}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevTag.java b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevTag.java
index cac2571..3bcdfaf 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevTag.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevTag.java
@@ -18,7 +18,9 @@
 import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
 import java.nio.charset.UnsupportedCharsetException;
+import java.util.Arrays;
 
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -35,6 +37,10 @@
  * An annotated tag.
  */
 public class RevTag extends RevObject {
+
+	private static final byte[] hSignature = Constants
+			.encodeASCII("-----BEGIN PGP SIGNATURE-----"); //$NON-NLS-1$
+
 	/**
 	 * Parse an annotated tag from its canonical format.
 	 *
@@ -171,6 +177,62 @@
 		return RawParseUtils.parsePersonIdent(raw, nameB);
 	}
 
+	private static int nextStart(byte[] prefix, byte[] buffer, int from) {
+		int stop = buffer.length - prefix.length + 1;
+		int ptr = from;
+		if (ptr > 0) {
+			ptr = RawParseUtils.nextLF(buffer, ptr - 1);
+		}
+		while (ptr < stop) {
+			int lineStart = ptr;
+			boolean found = true;
+			for (byte element : prefix) {
+				if (element != buffer[ptr++]) {
+					found = false;
+					break;
+				}
+			}
+			if (found) {
+				return lineStart;
+			}
+			do {
+				ptr = RawParseUtils.nextLF(buffer, ptr);
+			} while (ptr < stop && buffer[ptr] == '\n');
+		}
+		return -1;
+	}
+
+	/**
+	 * Parse the GPG signature from the raw buffer.
+	 *
+	 * @return contents of the GPG signature; {@code null} if the tag was not
+	 *         signed.
+	 * @since 5.11
+	 */
+	@Nullable
+	public final byte[] getRawGpgSignature() {
+		byte[] raw = buffer;
+		int msgB = RawParseUtils.tagMessage(raw, 0);
+		if (msgB < 0) {
+			return null;
+		}
+		// Find the last signature start and return the rest
+		int start = nextStart(hSignature, raw, msgB);
+		if (start < 0) {
+			return null;
+		}
+		int next = RawParseUtils.nextLF(raw, start);
+		while (next < raw.length) {
+			int newStart = nextStart(hSignature, raw, next);
+			if (newStart < 0) {
+				break;
+			}
+			start = newStart;
+			next = RawParseUtils.nextLF(raw, start);
+		}
+		return Arrays.copyOfRange(raw, start, raw.length);
+	}
+
 	/**
 	 * Parse the complete tag message and decode it to a string.
 	 * <p>