diff --git a/src/main/resources/update/gitbucket-core_4.31.xml b/src/main/resources/update/gitbucket-core_4.31.xml new file mode 100644 index 0000000..dcde9af --- /dev/null +++ b/src/main/resources/update/gitbucket-core_4.31.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/scala/gitbucket/core/GitBucketCoreModule.scala b/src/main/scala/gitbucket/core/GitBucketCoreModule.scala index d88cdea..83d519a 100644 --- a/src/main/scala/gitbucket/core/GitBucketCoreModule.scala +++ b/src/main/scala/gitbucket/core/GitBucketCoreModule.scala @@ -60,5 +60,6 @@ new Version("4.28.0"), new Version("4.29.0"), new Version("4.30.0"), - new Version("4.30.1") + new Version("4.30.1"), + new Version("4.31.0", new LiquibaseMigration("update/gitbucket-core_4.31.xml")) ) diff --git a/src/main/scala/gitbucket/core/controller/AccountController.scala b/src/main/scala/gitbucket/core/controller/AccountController.scala index 005aa8a..da07cde 100644 --- a/src/main/scala/gitbucket/core/controller/AccountController.scala +++ b/src/main/scala/gitbucket/core/controller/AccountController.scala @@ -26,6 +26,7 @@ with WikiService with LabelsService with SshKeyService + with GpgKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator @@ -42,6 +43,7 @@ with WikiService with LabelsService with SshKeyService + with GpgKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator @@ -75,6 +77,8 @@ case class SshKeyForm(title: String, publicKey: String) + case class GpgKeyForm(title: String, publicKey: String) + case class PersonalTokenForm(note: String) val newForm = mapping( @@ -108,6 +112,11 @@ "publicKey" -> trim2(label("Key", text(required, validPublicKey))) )(SshKeyForm.apply) + val gpgKeyForm = mapping( + "title" -> trim(label("Title", text(required, maxlength(100)))), + "publicKey" -> label("Key", text(required, validGpgPublicKey)) + )(GpgKeyForm.apply) + val personalTokenForm = mapping( "note" -> trim(label("Token", text(required, maxlength(100)))) )(PersonalTokenForm.apply) @@ -387,6 +396,27 @@ redirect(s"/${userName}/_ssh") }) + get("/:userName/_gpg")(oneselfOnly { + val userName = params("userName") + getAccountByUserName(userName).map { x => + //html.ssh(x, getPublicKeys(x.userName)) + html.gpg(x, getGpgPublicKeys(x.userName)) + } getOrElse NotFound() + }) + + post("/:userName/_gpg", gpgKeyForm)(oneselfOnly { form => + val userName = params("userName") + addGpgPublicKey(userName, form.title, form.publicKey) + redirect(s"/${userName}/_gpg") + }) + + get("/:userName/_gpg/delete/:id")(oneselfOnly { + val userName = params("userName") + val keyId = params("id").toInt + deleteGpgPublicKey(userName, keyId) + redirect(s"/${userName}/_gpg") + }) + get("/:userName/_application")(oneselfOnly { val userName = params("userName") getAccountByUserName(userName).map { x => @@ -771,6 +801,20 @@ } } + private def validGpgPublicKey: Constraint = new Constraint() { + override def validate(name: String, value: String, messages: Messages): Option[String] = { + GpgUtil.str2GpgKeyId(value) match { + case Some(s) if GpgUtil.getGpgKey(s).isEmpty => + None + case Some(_) => + Some("GPG key is duplicated.") + case None => + Some("GPG key is invalid.") + } + } + + } + private def validAccountName: Constraint = new Constraint() { override def validate(name: String, value: String, messages: Messages): Option[String] = { getAccountByUserName(value) match { diff --git a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala index d81a026..7ae412e 100644 --- a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala +++ b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala @@ -14,6 +14,7 @@ import gitbucket.core.util.Implicits._ import gitbucket.core.util.Directory._ import gitbucket.core.model.{Account, CommitState, CommitStatus} +import gitbucket.core.util.JGitUtil.CommitInfo import gitbucket.core.view import gitbucket.core.view.helpers import org.apache.commons.compress.archivers.{ArchiveEntry, ArchiveOutputStream} @@ -273,9 +274,30 @@ if (path.isEmpty) Nil else path.split("/").toList, branchName, repository, - logs.splitWith { (commit1, commit2) => - view.helpers.date(commit1.commitTime) == view.helpers.date(commit2.commitTime) - }, + logs + .map { + c => + CommitInfo( + id = c.id, + shortMessage = c.shortMessage, + fullMessage = c.fullMessage, + parents = c.parents, + authorTime = c.authorTime, + authorName = c.authorName, + authorEmailAddress = c.authorEmailAddress, + commitTime = c.commitTime, + committerName = c.committerName, + committerEmailAddress = c.committerEmailAddress, + commitSign = c.commitSign, + verified = c.commitSign + .flatMap { s => + GpgUtil.verifySign(s) + } + ) + } + .splitWith { (commit1, commit2) => + view.helpers.date(commit1.commitTime) == view.helpers.date(commit2.commitTime) + }, page, hasNext, hasDeveloperRole(repository.owner, repository.name, context.loginAccount), diff --git a/src/main/scala/gitbucket/core/model/GpgKey.scala b/src/main/scala/gitbucket/core/model/GpgKey.scala new file mode 100644 index 0000000..d4859f0 --- /dev/null +++ b/src/main/scala/gitbucket/core/model/GpgKey.scala @@ -0,0 +1,29 @@ +package gitbucket.core.model + +trait GpgKeyComponent { self: Profile => + import profile.api._ + + lazy val GpgKeys = TableQuery[GpgKeys] + + class GpgKeys(tag: Tag) extends Table[GpgKey](tag, "GPG_KEY") { + val userName = column[String]("USER_NAME") + val keyId = column[Int]("KEY_ID", O AutoInc) + val gpgKeyId = column[Long]("GPG_KEY_ID") + val title = column[String]("TITLE") + val publicKey = column[String]("PUBLIC_KEY") + def * = (userName, keyId, gpgKeyId, title, publicKey) <> (GpgKey.tupled, GpgKey.unapply) + + def byPrimaryKey(userName: String, keyId: Int) = + (this.userName === userName.bind) && (this.keyId === keyId.bind) + def byGpgKeyId(gpgKeyId: Long) = + this.gpgKeyId === gpgKeyId.bind + } +} + +case class GpgKey( + userName: String, + keyId: Int = 0, + gpgKeyId: Long, + title: String, + publicKey: String +) diff --git a/src/main/scala/gitbucket/core/model/Profile.scala b/src/main/scala/gitbucket/core/model/Profile.scala index 577c3ee..ce1f96b 100644 --- a/src/main/scala/gitbucket/core/model/Profile.scala +++ b/src/main/scala/gitbucket/core/model/Profile.scala @@ -59,6 +59,7 @@ with PullRequestComponent with RepositoryComponent with SshKeyComponent + with GpgKeyComponent with RepositoryWebHookComponent with RepositoryWebHookEventComponent with AccountWebHookComponent diff --git a/src/main/scala/gitbucket/core/service/GpgKeyService.scala b/src/main/scala/gitbucket/core/service/GpgKeyService.scala new file mode 100644 index 0000000..3e10b93 --- /dev/null +++ b/src/main/scala/gitbucket/core/service/GpgKeyService.scala @@ -0,0 +1,29 @@ +package gitbucket.core.service + +import java.io.ByteArrayInputStream + +import gitbucket.core.model.GpgKey + +import collection.JavaConverters._ +import gitbucket.core.model.Profile._ +import gitbucket.core.model.Profile.profile.blockingApi._ +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.bc.BcPGPObjectFactory + +trait GpgKeyService { + def getGpgPublicKeys(userName: String)(implicit s: Session): List[GpgKey] = + GpgKeys.filter(_.userName === userName.bind).sortBy(_.gpgKeyId).list + + def addGpgPublicKey(userName: String, title: String, publicKey: String)(implicit s: Session): Unit = { + val pubKeyOf = new BcPGPObjectFactory(new ArmoredInputStream(new ByteArrayInputStream(publicKey.getBytes))) + pubKeyOf.iterator().asScala.foreach { + case keyRing: PGPPublicKeyRing => + val key = keyRing.getPublicKey() + GpgKeys.insert(GpgKey(userName = userName, gpgKeyId = key.getKeyID, title = title, publicKey = publicKey)) + } + } + + def deleteGpgPublicKey(userName: String, keyId: Int)(implicit s: Session): Unit = + GpgKeys.filter(_.byPrimaryKey(userName, keyId)).delete +} diff --git a/src/main/scala/gitbucket/core/util/GpgUtil.scala b/src/main/scala/gitbucket/core/util/GpgUtil.scala new file mode 100644 index 0000000..5d5edb4 --- /dev/null +++ b/src/main/scala/gitbucket/core/util/GpgUtil.scala @@ -0,0 +1,61 @@ +package gitbucket.core.util +import java.io.ByteArrayInputStream + +import collection.JavaConverters._ +import gitbucket.core.model.Profile._ +import gitbucket.core.model.Profile.profile.blockingApi._ +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.{PGPPublicKey, PGPPublicKeyRing, PGPSignatureList} +import org.bouncycastle.openpgp.bc.BcPGPObjectFactory +import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider + +object GpgUtil { + def str2GpgKeyId(keyStr: String): Option[Long] = { + val pubKeyOf = new BcPGPObjectFactory(new ArmoredInputStream(new ByteArrayInputStream(keyStr.getBytes))) + pubKeyOf.iterator().asScala.collectFirst { + case keyRing: PGPPublicKeyRing => + keyRing.getPublicKey().getKeyID + } + } + + def getGpgKey(gpgKeyId: Long)(implicit s: Session): Option[PGPPublicKey] = { + val pubKeyOpt = GpgKeys.filter(_.byGpgKeyId(gpgKeyId)).map { _.publicKey }.firstOption + pubKeyOpt.flatMap { pubKeyStr => + val pubKeyObjFactory = + new BcPGPObjectFactory(new ArmoredInputStream(new ByteArrayInputStream(pubKeyStr.getBytes()))) + pubKeyObjFactory.nextObject() match { + case pubKeyRing: PGPPublicKeyRing => + Option(pubKeyRing.getPublicKey(gpgKeyId)) + case _ => + None + } + } + } + + def verifySign(signInfo: JGitUtil.GpgSignInfo)(implicit s: Session): Option[JGitUtil.GpgVerifyInfo] = { + new BcPGPObjectFactory(new ArmoredInputStream(new ByteArrayInputStream(signInfo.signArmored))) + .iterator() + .asScala + .flatMap { + case signList: PGPSignatureList => + signList + .iterator() + .asScala + .flatMap { sign => + getGpgKey(sign.getKeyID) + .map { pubKey => + sign.init(new BcPGPContentVerifierBuilderProvider, pubKey) + sign.update(signInfo.target) + (sign, pubKey) + } + .collect { + case (sign, pubKey) if sign.verify() => + JGitUtil.GpgVerifyInfo(pubKey.getUserIDs.next, pubKey.getKeyID.toHexString.toUpperCase) + } + } + + } + .toList + .headOption + } +} diff --git a/src/main/scala/gitbucket/core/util/JGitUtil.scala b/src/main/scala/gitbucket/core/util/JGitUtil.scala index 16d6e8e..e875a2b 100644 --- a/src/main/scala/gitbucket/core/util/JGitUtil.scala +++ b/src/main/scala/gitbucket/core/util/JGitUtil.scala @@ -1,6 +1,6 @@ package gitbucket.core.util -import java.io.{ByteArrayOutputStream, File, FileInputStream, InputStream} +import java.io._ import gitbucket.core.service.RepositoryService import org.eclipse.jgit.api.Git @@ -76,6 +76,59 @@ ) /** + * The gpg commit sign data. + * @param signArmored signature for commit + * @param target string for verification target + */ + case class GpgSignInfo(signArmored: Array[Byte], target: Array[Byte]) + + /** + * The verified gpg sign data. + * @param signedUser + * @param signedKeyId + */ + case class GpgVerifyInfo(signedUser: String, signedKeyId: String) + + private def getSignTarget(rev: RevCommit): Array[Byte] = { + val ascii = "ASCII" + val os = new ByteArrayOutputStream() + val w = new OutputStreamWriter(os, rev.getEncoding) + os.write("tree ".getBytes(ascii)) + rev.getTree.copyTo(os) + os.write('\n') + + rev.getParents.foreach { p => + os.write("parent ".getBytes(ascii)) + p.copyTo(os) + os.write('\n') + } + + os.write("author ".getBytes(ascii)) + w.write(rev.getAuthorIdent.toExternalString) + w.flush() + os.write('\n') + + os.write("committer ".getBytes(ascii)) + w.write(rev.getCommitterIdent.toExternalString) + w.flush() + os.write('\n') + + if (rev.getEncoding.name != "UTF-8") { + os.write("encoding ".getBytes(ascii)) + os.write(Constants.encodeASCII(rev.getEncoding.name)) + os.write('\n') + } + + os.write('\n') + + if (!rev.getFullMessage.isEmpty) { + w.write(rev.getFullMessage) + w.flush() + } + os.toByteArray + } + + /** * The commit data. * * @param id the commit id @@ -99,7 +152,9 @@ authorEmailAddress: String, commitTime: Date, committerName: String, - committerEmailAddress: String + committerEmailAddress: String, + commitSign: Option[GpgSignInfo], + verified: Option[GpgVerifyInfo] ) { def this(rev: org.eclipse.jgit.revwalk.RevCommit) = @@ -113,7 +168,11 @@ rev.getAuthorIdent.getEmailAddress, rev.getCommitterIdent.getWhen, rev.getCommitterIdent.getName, - rev.getCommitterIdent.getEmailAddress + rev.getCommitterIdent.getEmailAddress, + Option(rev.getRawGpgSignature).map { s => + GpgSignInfo(s, getSignTarget(rev)) + }, + None ) val summary = getSummaryMessage(fullMessage, shortMessage) diff --git a/src/main/twirl/gitbucket/core/account/gpg.scala.html b/src/main/twirl/gitbucket/core/account/gpg.scala.html new file mode 100644 index 0000000..e453124 --- /dev/null +++ b/src/main/twirl/gitbucket/core/account/gpg.scala.html @@ -0,0 +1,39 @@ +@(account: gitbucket.core.model.Account, gpgKeys: List[gitbucket.core.model.GpgKey])(implicit context: gitbucket.core.controller.Context) +@import gitbucket.core.ssh.SshUtil +@gitbucket.core.html.main("GPG Keys"){ + @gitbucket.core.account.html.menu("gpg", context.loginAccount.get.userName, false){ +
+
GPG Keys
+
+ @if(gpgKeys.isEmpty){ + No keys + } + @gpgKeys.zipWithIndex.map { case (key, i) => + @if(i != 0){ +
+ } + @key.title (@key.gpgKeyId.toHexString.toUpperCase) + Delete + } +
+
+
+
+
Add a GPG Key
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ } +} diff --git a/src/main/twirl/gitbucket/core/account/menu.scala.html b/src/main/twirl/gitbucket/core/account/menu.scala.html index 07fc46b..b7ed72d 100644 --- a/src/main/twirl/gitbucket/core/account/menu.scala.html +++ b/src/main/twirl/gitbucket/core/account/menu.scala.html @@ -26,6 +26,11 @@ } +