mirror of
https://github.com/gitbucket/gitbucket.git
synced 2025-11-02 11:36:05 +01:00
Verify gpg sign (#2264)
This commit is contained in:
committed by
Naoki Takezoe
parent
33277bf25f
commit
8705d3450a
16
src/main/resources/update/gitbucket-core_4.31.xml
Normal file
16
src/main/resources/update/gitbucket-core_4.31.xml
Normal 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>
|
||||
@@ -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"))
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
29
src/main/scala/gitbucket/core/model/GpgKey.scala
Normal file
29
src/main/scala/gitbucket/core/model/GpgKey.scala
Normal 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
|
||||
)
|
||||
@@ -59,6 +59,7 @@ trait CoreProfile
|
||||
with PullRequestComponent
|
||||
with RepositoryComponent
|
||||
with SshKeyComponent
|
||||
with GpgKeyComponent
|
||||
with RepositoryWebHookComponent
|
||||
with RepositoryWebHookEventComponent
|
||||
with AccountWebHookComponent
|
||||
|
||||
29
src/main/scala/gitbucket/core/service/GpgKeyService.scala
Normal file
29
src/main/scala/gitbucket/core/service/GpgKeyService.scala
Normal 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
|
||||
}
|
||||
61
src/main/scala/gitbucket/core/util/GpgUtil.scala
Normal file
61
src/main/scala/gitbucket/core/util/GpgUtil.scala
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
39
src/main/twirl/gitbucket/core/account/gpg.scala.html
Normal file
39
src/main/twirl/gitbucket/core/account/gpg.scala.html
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user