Verify gpg sign (#2264)

This commit is contained in:
Yuusuke KOUNOIKE
2019-03-16 17:29:52 +09:00
committed by Naoki Takezoe
parent 33277bf25f
commit 8705d3450a
13 changed files with 323 additions and 8 deletions

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<changeSet>
<!--================================================================================================-->
<!-- SSH_KEY -->
<!--================================================================================================-->
<createTable tableName="GPG_KEY">
<column name="USER_NAME" type="varchar(100)" nullable="false"/>
<column name="KEY_ID" type="int" nullable="false" autoIncrement="true" unique="true"/>
<column name="GPG_KEY_ID" type="bigint" nullable="false"/>
<column name="TITLE" type="varchar(100)" nullable="false"/>
<column name="PUBLIC_KEY" type="text" nullable="false"/>
</createTable>
<addPrimaryKey constraintName="IDX_GPG_KEY_PK" tableName="GPG_KEY" columnNames="USER_NAME, GPG_KEY_ID"/>
<addForeignKeyConstraint constraintName="IDX_GPG_KEY_FK0" baseTableName="GPG_KEY" baseColumnNames="USER_NAME" referencedTableName="ACCOUNT" referencedColumnNames="USER_NAME"/>
</changeSet>

View File

@@ -60,5 +60,6 @@ object GitBucketCoreModule
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"))
)

View File

@@ -26,6 +26,7 @@ class AccountController
with WikiService
with LabelsService
with SshKeyService
with GpgKeyService
with OneselfAuthenticator
with UsersAuthenticator
with GroupManagerAuthenticator
@@ -42,6 +43,7 @@ trait AccountControllerBase extends AccountManagementControllerBase {
with WikiService
with LabelsService
with SshKeyService
with GpgKeyService
with OneselfAuthenticator
with UsersAuthenticator
with GroupManagerAuthenticator
@@ -75,6 +77,8 @@ trait AccountControllerBase extends AccountManagementControllerBase {
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 @@ trait AccountControllerBase extends AccountManagementControllerBase {
"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 @@ trait AccountControllerBase extends AccountManagementControllerBase {
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 @@ trait AccountControllerBase extends AccountManagementControllerBase {
}
}
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 {

View File

@@ -14,6 +14,7 @@ import gitbucket.core.util.SyntaxSugars._
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 @@ trait RepositoryViewerControllerBase extends ControllerBase {
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),

View File

@@ -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
)

View File

@@ -59,6 +59,7 @@ trait CoreProfile
with PullRequestComponent
with RepositoryComponent
with SshKeyComponent
with GpgKeyComponent
with RepositoryWebHookComponent
with RepositoryWebHookEventComponent
with AccountWebHookComponent

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
@@ -75,6 +75,59 @@ object JGitUtil {
linkUrl: Option[String]
)
/**
* 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.
*
@@ -99,7 +152,9 @@ object JGitUtil {
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 @@ object JGitUtil {
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)

View File

@@ -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){
<div class="panel panel-default">
<div class="panel-heading strong">GPG Keys</div>
<div class="panel-body">
@if(gpgKeys.isEmpty){
No keys
}
@gpgKeys.zipWithIndex.map { case (key, i) =>
@if(i != 0){
<hr>
}
<strong style="line-height: 30px;">@key.title</strong> (@key.gpgKeyId.toHexString.toUpperCase)
<a href="@context.path/@account.userName/_gpg/delete/@key.keyId" class="btn btn-sm btn-danger pull-right">Delete</a>
}
</div>
</div>
<form method="POST" action="@context.path/@account.userName/_gpg" validate="true" autocomplete="off">
<div class="panel panel-default">
<div class="panel-heading strong">Add a GPG Key</div>
<div class="panel-body">
<fieldset class="form-group">
<label for="title" class="strong">Title</label>
<div><span id="error-title" class="error"></span></div>
<input type="text" name="title" id="title" class="form-control"/>
</fieldset>
<fieldset class="form-group">
<label for="publicKey" class="strong">Key</label>
<div><span id="error-publicKey" class="error"></span></div>
<textarea name="publicKey" id="publicKey" class="form-control" style="height: 200px;"></textarea>
</fieldset>
<input type="submit" class="btn btn-success" value="Add"/>
</div>
</div>
</form>
}
}

View File

@@ -26,6 +26,11 @@
</a>
</li>
}
<li class="menu-item-hover @if(active=="gpg"){active}">
<a href="@context.path/@userName/_gpg">
<i class="menu-icon octicon octicon-key"></i> <span>GPG Keys</span>
</a>
</li>
<li class="menu-item-hover @if(active=="application"){active}">
<a href="@context.path/@userName/_application">
<i class="menu-icon octicon octicon-rocket"></i> <span>Applications</span>

View File

@@ -40,6 +40,13 @@
@if(i != 0){ <tr> }
<td>
<div class="pull-right text-right">
@if(commit.commitSign.isDefined){
@commit.verified.map{ v =>
<span class="gpg-verified">Verified signed by @v.signedUser (@v.signedKeyId)</span>
}.getOrElse{
<span class="gpg-unverified">Unverified</span>
}
}
@defining(getTags(commit.id)) { tags =>
@if(tags.nonEmpty){
<span class="muted">

View File

@@ -250,7 +250,9 @@ object ApiSpecModels {
authorEmailAddress = account.mailAddress,
commitTime = date1,
committerName = account.userName,
committerEmailAddress = account.mailAddress
committerEmailAddress = account.mailAddress,
None,
None
)
val apiCommitListItem = ApiCommitListItem(