diff options
author | Jakob Odersky <jodersky@gmail.com> | 2015-04-22 15:06:49 +0200 |
---|---|---|
committer | Jakob Odersky <jodersky@gmail.com> | 2015-04-22 15:24:00 +0200 |
commit | f79ee0e3999dfd04af306aced213f20b7f8e0904 (patch) | |
tree | ff9be23960cce44544a90bee37124d0cdcd2f60d /gpg/skeybase/src/main/scala/com/github/jodersky/skeybase | |
download | security-f79ee0e3999dfd04af306aced213f20b7f8e0904.tar.gz security-f79ee0e3999dfd04af306aced213f20b7f8e0904.tar.bz2 security-f79ee0e3999dfd04af306aced213f20b7f8e0904.zip |
initial commit
Diffstat (limited to 'gpg/skeybase/src/main/scala/com/github/jodersky/skeybase')
10 files changed, 405 insertions, 0 deletions
diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Keybase.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Keybase.scala new file mode 100644 index 0000000..bfc246e --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Keybase.scala @@ -0,0 +1,61 @@ +package com.github.jodersky.skeybase + +import scala.concurrent.Future + +import akka.actor.ActorSystem +import spray.client.pipelining.Get +import spray.client.pipelining.WithTransformerConcatenation +import spray.client.pipelining.sendReceive +import spray.client.pipelining.sendReceive$default$3 +import spray.client.pipelining.unmarshal +import spray.httpx.SprayJsonSupport.sprayJsonUnmarshaller +import spray.json.DefaultJsonProtocol +import spray.json.DeserializationException +import spray.json.JsArray +import spray.json.JsObject +import spray.json.JsValue +import spray.json.RootJsonFormat + +object Keybase { + def origin = new Keybase("https://keybase.io") + + object JsonProtocol extends DefaultJsonProtocol { + implicit val basicsFormat = jsonFormat1(Basics.apply) + implicit val proofFormat = jsonFormat(Proof.apply, "nametag", "proof_type", "proof_url") + + implicit object PrimaryKeyFormat extends RootJsonFormat[PublicKey] { + def write(key: PublicKey) = throw new NotImplementedError + def read(value: JsValue) = value.asJsObject.getFields("primary") match { + case Seq(JsObject(data)) => data.get("key_fingerprint") map (f => PublicKey(f.convertTo[String].toUpperCase())) getOrElse { + throw new DeserializationException("Fingerprint expected") + } + case _ => throw new DeserializationException("Primary key expected") + } + } + + implicit object ProofsFormat extends RootJsonFormat[Seq[Proof]] { + def write(proofs: Seq[Proof]) = throw new NotImplementedError + def read(value: JsValue) = value.asJsObject.getFields("all") match { + case Seq(JsArray(values)) => values.map(_.convertTo[Proof]) + case _ => throw new DeserializationException("Proofs array expected") + } + } + implicit val userFormat = jsonFormat(User.apply, "basics", "proofs_summary", "public_keys") + implicit val lookupFormat = jsonFormat1(LookupResponse.apply) + } +} + +class Keybase(host: String) { + + import Keybase.JsonProtocol._ + + def lookup(username: String)(implicit system: ActorSystem): Future[User] = { + import system.dispatcher + + val lookup = sendReceive ~> unmarshal[LookupResponse] + val url = host + "/_/api/1.0/user/lookup.json?usernames=" + username + "&fields=proofs_summary,public_keys" + + lookup(Get(url)).map(_.them.head) + } + +}
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Main.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Main.scala new file mode 100644 index 0000000..1553b96 --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Main.scala @@ -0,0 +1,39 @@ +package com.github.jodersky.skeybase + +import scala.concurrent.Await +import scala.concurrent.duration._ +import akka.actor.ActorSystem +import scala.language.implicitConversions +import scala.util.Success +import scala.util.Failure +import openpgp.GnuPG +import verification.GitHubVerifier +import verification.VerificationException + +object Main { + + def main(args: Array[String]): Unit = { + implicit val system = ActorSystem() + import system.dispatcher + + val verifier = new GitHubVerifier(new GnuPG()) + + val proofs = for ( + user <- Keybase.origin.lookup("jodersky"); + github = user.proofs.find(_.proofType == "github").get; + verification <- verifier.verify(user.key.fingerprint, github) + ) yield { + verification + } + + proofs onComplete { result => + result match { + case Success(proof) => println("done") + case Failure(err: VerificationException) => println("Verification exception! Someone may be doing something nasty.") + case Failure(err) => err.printStackTrace() + } + system.shutdown() + } + } + +}
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/openpgp/Backend.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/openpgp/Backend.scala new file mode 100644 index 0000000..108ee00 --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/openpgp/Backend.scala @@ -0,0 +1,18 @@ +package com.github.jodersky.skeybase +package openpgp + +import scala.util.Try + +trait Backend { + + /** Imports a key into this backend so that it is available for verification. */ + def importKey(key: String): Unit + + /** + * verifies a signed statement. + * @param signed the statement to verify + * @param fingerprint the fingerprint of the key that allegedly signed this statement + */ + def verifySignature(signed: String, fingerprint: String): Try[String] + +}
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/openpgp/GnuPG.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/openpgp/GnuPG.scala new file mode 100644 index 0000000..3b2d152 --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/openpgp/GnuPG.scala @@ -0,0 +1,58 @@ +package com.github.jodersky.skeybase +package openpgp + +import java.io.File +import scala.sys.process._ +import java.io.ByteArrayInputStream +import scala.collection.mutable.ArrayBuffer +import verification.VerificationException +import scala.util.Try + +class GnuPG( + val home: File = new File("."), + val command: String = "/usr/bin/gpg") + extends Backend { + + import GnuPG._ + + private val _gpg = s"${command} --home=${home.getAbsolutePath} --no-default-keyring --keyring=temp.gpg --status-fd=2" + private def gpg(args: String) = _gpg + " " + args + + def importKey(key: String) = { + val result = (gpg("--import -") #< stream(key)).! + result == 0 + } + + def verifySignature(statement: String, fingerprint: String): Try[String] = Try{ + val stdout = new StringBuilder + val stderr = new ArrayBuffer[String] + + val status = (gpg("-d -") #< stream(statement)) ! ProcessLogger(stdout append _, stderr append _) + + if (status != 0) throw new VerificationException("gpg exited with non-zero exit code") + + /* see doc/DETAILS of GnuPG for more information about structure */ + def fpr(line: String) = """\[GNUPG:\] VALIDSIG (\S+\s+){9}(\w+)""".r findPrefixMatchOf (line) map { m => + m.group(2) + } + + val valid = stderr find (fpr(_) == Some(fingerprint)) + + if (valid.isEmpty) { + throw new VerificationException("Statement is not signed by the correct key.") + } else { + stdout.toString() + } + } + +} + +object GnuPG { + + private def stream(str: String) = { + val bytes = str.getBytes("UTF-8") + new ByteArrayInputStream(bytes) + } + + val tmp = "~/.skeybase" +}
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/responses.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/responses.scala new file mode 100644 index 0000000..79ff9be --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/responses.scala @@ -0,0 +1,7 @@ +package com.github.jodersky.skeybase + +case class PublicKey(fingerprint: String) +case class Proof(nametag: String, proofType: String, proofUrl: String) +case class Basics(username: String) +case class LookupResponse(them: Seq[User]) +case class User(basics: Basics, proofs: Seq[Proof], key: PublicKey)
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/GitHubVerifier.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/GitHubVerifier.scala new file mode 100644 index 0000000..5243b36 --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/GitHubVerifier.scala @@ -0,0 +1,64 @@ +package com.github.jodersky.skeybase +package verification + +import scala.concurrent.Future + +import Verifier.extractSignedStatement +import Verifier.finalHost +import Verifier.verifyStatement +import Verifier.withRedirects +import akka.actor.ActorSystem +import openpgp.Backend +import spray.client.pipelining.Get +import spray.client.pipelining.WithTransformerConcatenation +import spray.client.pipelining.sendReceive +import spray.client.pipelining.sendReceive$default$3 +import spray.client.pipelining.unmarshal +import spray.httpx.SprayJsonSupport.sprayJsonUnmarshaller +import spray.json.DefaultJsonProtocol + +object GitHubVerifier { + case class GistFile(rawUrl: String) + case class Gist(url: String, files: Map[String, GistFile]) + + object GitHubProtocol extends DefaultJsonProtocol { + implicit val gistFileFormat = jsonFormat(GistFile, "raw_url") + implicit val gistFormat = jsonFormat2(Gist) + } +} + +class GitHubVerifier(backend: Backend) extends Verifier { + import Verifier._ + import GitHubVerifier._ + import GitHubVerifier.GitHubProtocol._ + + def verify(fingerprint: String, proof: Proof)(implicit sys: ActorSystem) = { + import sys.dispatcher + + val urlOfHeadGist = (gists: Seq[Gist]) => { + val url = for ( + gist <- gists.headOption; + (_, file) <- gist.files.headOption + ) yield { + file.rawUrl + } + url getOrElse { + throw new NoSuchElementException("No gist found.") + } + } + val gistPipeline = withRedirects(sendReceive) ~> finalHost("api.github.com").tupled ~> unmarshal[Seq[Gist]] ~> urlOfHeadGist + val rawPipeline = sendReceive ~> unmarshal[String] + + for ( + rawUrl <- gistPipeline(Get("https://api.github.com/users/" + proof.nametag + "/gists")); + content <- rawPipeline(Get(rawUrl)); + signed <- extractSignedStatement(content); + clear <- backend.verifySignature(signed, fingerprint); + verified <- verifyStatement(clear, "github", proof.nametag) + ) yield { + proof + } + } + +} +
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/VerificationException.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/VerificationException.scala new file mode 100644 index 0000000..a7c1f78 --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/VerificationException.scala @@ -0,0 +1,4 @@ +package com.github.jodersky.skeybase +package verification + +class VerificationException(message: String) extends RuntimeException(message)
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/Verifier.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/Verifier.scala new file mode 100644 index 0000000..6025fef --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/Verifier.scala @@ -0,0 +1,109 @@ +package com.github.jodersky.skeybase +package verification + +import scala.language.implicitConversions + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future +import scala.util.Failure +import scala.util.Success +import scala.util.Try + +import com.github.jodersky.skeybase.Proof +import com.github.jodersky.skeybase.PublicKey + +import akka.actor.ActorSystem +import spray.http.HttpHeaders.Location +import spray.http.HttpRequest +import spray.http.HttpResponse +import spray.http.Uri +import spray.json.DefaultJsonProtocol +import spray.json.JsonParser +import spray.json.ParserInput.apply + +trait Verifier { + + def verify(fingerprint: String, proof: Proof)(implicit sys: ActorSystem): Future[Proof] + +} + +object Verifier { + + object JsonProtocol extends DefaultJsonProtocol { + implicit val serviceFormat = jsonFormat2(Service.apply) + implicit val keyFormat = jsonFormat1(PublicKey.apply) + implicit val statementBodyFormat = jsonFormat2(StatementBody.apply) + implicit val statementFormat = jsonFormat1(Statement.apply) + } + import JsonProtocol._ + + implicit def tryToFuture[A](t: Try[A]): Future[A] = t match { + case Success(a) => Future.successful(a) + case Failure(e) => Future.failed(e) + } + + def withRedirects( + sendReceive: HttpRequest => Future[HttpResponse], + maxRedirects: Int = 5)(implicit ec: ExecutionContext): HttpRequest => Future[(Uri, HttpResponse)] = { request => + + def dispatch(request: HttpRequest, redirectsLeft: Int): Future[(Uri, HttpResponse)] = if (redirectsLeft <= 0) { + Future.failed(new RuntimeException("Too many redirects.")) + } else { + sendReceive(request).flatMap { response => + if (response.status.value.startsWith("3")) { + response.header[Location].map { location => + dispatch(request.copy(uri = location.uri), redirectsLeft - 1) + } getOrElse { + Future.failed(new RuntimeException("Missing location header in redirect response.")) + } + } else { + Future.successful(request.uri, response) + } + } + } + + dispatch(request, maxRedirects) + } + + def finalHost(host: String) = (uri: Uri, response: HttpResponse) => { + if (uri.authority.host.address != host) + throw new VerificationException("Final host is not " + host) + else + response + } + + def extractSignedStatement(content: String): Try[String] = Try { + val regex = """(-----BEGIN PGP MESSAGE-----(.|\n)*-----END PGP MESSAGE-----?)""".r + regex.findFirstIn(content) getOrElse { + throw new VerificationException("No OpenPGP message found.") + } + } + + def verifyStatement(statement: String, service: String, username: String): Try[String] = Try { + val stmt = JsonParser(statement).convertTo[Statement] + + if (stmt.body.service.name != service) throw new VerificationException( + "The service specified in the signed statement (" + stmt.body.service.name + ") is not " + + "the same as the service under which the statement was found (" + service + ")") + else if (stmt.body.service.username != username) throw new VerificationException( + "The username specified in the signed statement (" + stmt.body.service.username + ") is not " + + "the same as the username under which the statement was found (" + username + ")") + else statement + + } + + /* + * if (!(uri.path.tail startsWith (Path(proof.nametag)))) { + * throw new VerificationException("Final github account does not match the one provided in the proof." + uri.path.head) + * } + + + def extractHtmlId(id: String, html: String): Option[String] = { + val cleaner = new HtmlCleaner + val root = cleaner.clean(html) + root.getElementsByName("div", true).find(_.getAttributeByName("id") == id).map { div => + StringEscapeUtils.unescapeHtml4(div.getText.toString()) + } + }*/ + +}
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/WebsiteFileVerifier.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/WebsiteFileVerifier.scala new file mode 100644 index 0000000..04cad1e --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/WebsiteFileVerifier.scala @@ -0,0 +1,39 @@ +package com.github.jodersky.skeybase +package verification + +import scala.concurrent.Future + + +import Verifier.extractSignedStatement +import Verifier.finalHost +import Verifier.verifyStatement +import Verifier.withRedirects +import akka.actor.ActorSystem +import openpgp.Backend +import spray.client.pipelining.Get +import spray.client.pipelining.WithTransformerConcatenation +import spray.client.pipelining.sendReceive +import spray.client.pipelining.sendReceive$default$3 +import spray.client.pipelining.unmarshal +import spray.httpx.SprayJsonSupport.sprayJsonUnmarshaller +import spray.json.DefaultJsonProtocol + +class WebsiteFileVerifier(backend: Backend) extends Verifier { + import Verifier._ + + def verify(fingerprint: String, proof: Proof)(implicit sys: ActorSystem) = { + import sys.dispatcher + + val pipeline = withRedirects(sendReceive) ~> finalHost(proof.nametag).tupled ~> unmarshal[String] + for ( + content <- pipeline(Get(proof.proofUrl)); + signed <- extractSignedStatement(content); + clear <- backend.verifySignature(signed, fingerprint); + verified <- verifyStatement(clear, "github", proof.nametag) + ) yield { + proof + } + } + +} +
\ No newline at end of file diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/statements.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/statements.scala new file mode 100644 index 0000000..cbe896f --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/statements.scala @@ -0,0 +1,6 @@ +package com.github.jodersky.skeybase +package verification + +case class Service(name: String, username: String) +case class StatementBody(key: PublicKey, service: Service) +case class Statement(body: StatementBody)
\ No newline at end of file |