diff options
author | Jakob Odersky <jodersky@gmail.com> | 2015-04-27 17:00:53 +0200 |
---|---|---|
committer | Jakob Odersky <jodersky@gmail.com> | 2015-04-27 17:00:53 +0200 |
commit | 56557445cd959315754e0de1ffbb000eeaf5f08c (patch) | |
tree | 41c7ebdb77398c6bcb5d74ebb97f5a2b3cecd3f1 /gpg/skeybase/src/main/scala | |
parent | f79ee0e3999dfd04af306aced213f20b7f8e0904 (diff) | |
download | security-56557445cd959315754e0de1ffbb000eeaf5f08c.tar.gz security-56557445cd959315754e0de1ffbb000eeaf5f08c.tar.bz2 security-56557445cd959315754e0de1ffbb000eeaf5f08c.zip |
Diffstat (limited to 'gpg/skeybase/src/main/scala')
11 files changed, 125 insertions, 83 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 index bfc246e..4a2e4ba 100644 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Keybase.scala +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Keybase.scala @@ -17,7 +17,9 @@ import spray.json.JsValue import spray.json.RootJsonFormat object Keybase { - def origin = new Keybase("https://keybase.io") + + //private JSON responses + protected case class LookupResponse(them: Seq[User]) object JsonProtocol extends DefaultJsonProtocol { implicit val basicsFormat = jsonFormat1(Basics.apply) @@ -45,16 +47,23 @@ object Keybase { } } -class Keybase(host: String) { - +/** + * Keybase.io API entry point. + * @param host the internet host implementing the Keybase API, defaults to "https://keybase.io" + */ +class Keybase(host: String = "https://keybase.io") { + import Keybase._ import Keybase.JsonProtocol._ + + private val entry = host + "/_/api/1.0/" + /** Retrieves a user from keybase */ 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" + val url = entry + "user/lookup.json?usernames=" + username + "&fields=proofs_summary,public_keys" + val lookup = sendReceive ~> unmarshal[LookupResponse] lookup(Get(url)).map(_.them.head) } 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 index 1553b96..460e221 100644 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Main.scala +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Main.scala @@ -7,8 +7,7 @@ import scala.language.implicitConversions import scala.util.Success import scala.util.Failure import openpgp.GnuPG -import verification.GitHubVerifier -import verification.VerificationException +import verifiers._ object Main { @@ -16,12 +15,12 @@ object Main { implicit val system = ActorSystem() import system.dispatcher - val verifier = new GitHubVerifier(new GnuPG()) + val verifier = new WebsiteFileVerifier(new GnuPG()) //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) + user <- new Keybase().lookup("jodersky"); + github = user.proofs.find(_.proofType == "generic_web_site").get; + verification <- verifier.verify(user.primaryKey.fingerprint, github) ) yield { verification } @@ -29,7 +28,7 @@ object Main { 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: VerificationException) => println("Verification exception! Someone may be doing something evil. " + err.getMessage) case Failure(err) => err.printStackTrace() } system.shutdown() diff --git a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/VerificationException.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/VerificationException.scala new file mode 100644 index 0000000..228c7c8 --- /dev/null +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/VerificationException.scala @@ -0,0 +1,8 @@ +package com.github.jodersky.skeybase + +/** + * Thrown when the verification of a proof fails. This exception may only be thrown in case of an error + * during verification (such as an invalid signature), NOT in related circumstances (such as an offline service + * or missing resource). + */ +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/Verifier.scala index 6025fef..ccf0308 100644 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/Verifier.scala +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/Verifier.scala @@ -1,5 +1,4 @@ package com.github.jodersky.skeybase -package verification import scala.language.implicitConversions @@ -8,10 +7,6 @@ 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 @@ -19,42 +14,46 @@ import spray.http.HttpResponse import spray.http.Uri import spray.json.DefaultJsonProtocol import spray.json.JsonParser -import spray.json.ParserInput.apply +/** Verifies a user identity proof. */ trait Verifier { + /** Checks if a given proof is actually signed by the provided key. */ def verify(fingerprint: String, proof: Proof)(implicit sys: ActorSystem): Future[Proof] } +/** Contains utilities for concrete verifiers. */ object Verifier { object JsonProtocol extends DefaultJsonProtocol { - implicit val serviceFormat = jsonFormat2(Service.apply) + implicit val serviceFormat = jsonFormat4(Service.apply) implicit val keyFormat = jsonFormat1(PublicKey.apply) implicit val statementBodyFormat = jsonFormat2(StatementBody.apply) - implicit val statementFormat = jsonFormat1(Statement.apply) + implicit val statementFormat = jsonFormat1(OwnershipStatement.apply) } import JsonProtocol._ + /** Convert a try to a future, useful for writing expressive for-comprehensions mixing futures and tries. */ implicit def tryToFuture[A](t: Try[A]): Future[A] = t match { case Success(a) => Future.successful(a) case Failure(e) => Future.failed(e) } + /** Pipeline stage that follows redirects and also keeps track of the final URL. */ 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.")) + Future.failed(new UnsupportedOperationException("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.")) + Future.failed(new NoSuchElementException("Missing location header in redirect response.")) } } else { Future.successful(request.uri, response) @@ -65,31 +64,35 @@ object Verifier { dispatch(request, maxRedirects) } - def finalHost(host: String) = (uri: Uri, response: HttpResponse) => { + /** Pipeline stage that ensures the request's host matches a provided parameter. */ + def finalHost(host: String) = ((uri: Uri, response: HttpResponse) => { if (uri.authority.host.address != host) - throw new VerificationException("Final host is not " + host) + throw new VerificationException("Final host " + uri.authority.host.address + " 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 { + }).tupled + + /** Extract an OpenPGP delimited message from a content. */ + def extractSignedMessage(content: String): Try[String] = Try { + val header = "-----BEGIN PGP MESSAGE-----" + val footer = "-----END PGP MESSAGE-----" + val inner = content.lines.dropWhile(_ != header).takeWhile(_ != footer) + if (inner.isEmpty) { throw new VerificationException("No OpenPGP message found.") + } else { + (inner ++ Seq(footer)).mkString("\n") } } - 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 - + /** Verify the contents of a statement of ownership against a known service. */ + def verifyOwnershipStatement(statement: String, goodService: Service): Try[OwnershipStatement] = Try { + val stmt = JsonParser(statement).convertTo[OwnershipStatement] + + if (stmt.body.service != goodService) { + throw new VerificationException("The statement of ownership does not match the required service.") + } else { + stmt + } } /* 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 index 108ee00..056f863 100644 --- 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 @@ -9,8 +9,8 @@ trait Backend { def importKey(key: String): Unit /** - * verifies a signed statement. - * @param signed the statement to verify + * Verifies a signed message. + * @param signed the message to verify * @param fingerprint the fingerprint of the key that allegedly signed this statement */ def verifySignature(signed: String, fingerprint: String): Try[String] 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 index 3b2d152..befc18c 100644 --- 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 @@ -5,17 +5,16 @@ 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") + val command: String = "gpg") extends Backend { import GnuPG._ - private val _gpg = s"${command} --home=${home.getAbsolutePath} --no-default-keyring --keyring=temp.gpg --status-fd=2" + private val _gpg = s"${command} --home=${home.getAbsolutePath} --no-default-keyring --keyring=keybase.gpg --status-fd=2" private def gpg(args: String) = _gpg + " " + args def importKey(key: String) = { @@ -23,13 +22,13 @@ class GnuPG( result == 0 } - def verifySignature(statement: String, fingerprint: String): Try[String] = Try{ + def verifySignature(message: 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 _) + val status = (gpg("-d -") #< stream(message)) ! ProcessLogger(stdout append _, stderr append _) - if (status != 0) throw new VerificationException("gpg exited with non-zero exit code") + if (status != 0) throw new VerificationException("GnuPG exited with non-zero exit code. Stderr: \n" + stderr.mkString("\n")) /* 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 => 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 index 79ff9be..9d9edf2 100644 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/responses.scala +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/responses.scala @@ -1,7 +1,38 @@ package com.github.jodersky.skeybase -case class PublicKey(fingerprint: String) -case class Proof(nametag: String, proofType: String, proofUrl: String) +/** A keybase user. */ +case class User(basics: Basics, proofs: Seq[Proof], primaryKey: PublicKey) + +/** Basic information about a user. */ 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 + +/** + * An identity "proof" consisting of an online, cryptographically signed statement asserting + * that a specific username of an online service has control of the signing key. + * @param nametag the name of the user controlling the key + * @param proofType string identifying the kind of proof, i.e. the service or website + * @param proofUrl the URL under which the proof can be found + */ +case class Proof(nametag: String, proofType: String, proofUrl: String) + +/** An OpenPGP public key belonging to a user, represented by its fingerprint*/ +case class PublicKey(fingerprint: String) + +/** + * A statement, usually available in a signed form, that asserts + * the ownership of a public key and service handle (i.e. a username + * or an entire website). + */ +case class OwnershipStatement(body: StatementBody) + +/** + * Actual meaningful contents of an ownership statement. + */ +case class StatementBody(key: PublicKey, service: Service) + +/** + * An online service, e.g. a social media handle or a website. + * Note: since keybase.io are inconsistent with their statements across various services, this class + * provides only optional fields. It is up to verifiers to ensure that the correct fields are set. + */ +case class Service(name: Option[String], username: Option[String], hostname: Option[String], domain: Option[String]) 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 deleted file mode 100644 index a7c1f78..0000000 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/VerificationException.scala +++ /dev/null @@ -1,4 +0,0 @@ -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/statements.scala b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/statements.scala deleted file mode 100644 index cbe896f..0000000 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/statements.scala +++ /dev/null @@ -1,6 +0,0 @@ -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 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/verifiers/GitHubVerifier.scala index 5243b36..dfcaa94 100644 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/GitHubVerifier.scala +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verifiers/GitHubVerifier.scala @@ -1,12 +1,7 @@ package com.github.jodersky.skeybase -package verification +package verifiers 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 @@ -46,15 +41,21 @@ class GitHubVerifier(backend: Backend) extends Verifier { throw new NoSuchElementException("No gist found.") } } - val gistPipeline = withRedirects(sendReceive) ~> finalHost("api.github.com").tupled ~> unmarshal[Seq[Gist]] ~> urlOfHeadGist + val gistPipeline = withRedirects(sendReceive) ~> finalHost("api.github.com") ~> unmarshal[Seq[Gist]] ~> urlOfHeadGist val rawPipeline = sendReceive ~> unmarshal[String] + val githubService = Service( + name = Some("github"), + username = Some(proof.nametag), + hostname = None, + domain = None) + 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) + rawUrl <- gistPipeline(Get("https://api.github.com/users/" + proof.nametag + "/gists")); // url of raw gist + content <- rawPipeline(Get(rawUrl)); // content of raw gist + signed <- extractSignedMessage(content); // signed statement of ownership + clear <- backend.verifySignature(signed, fingerprint); // verified against fingerprint + verified <- verifyOwnershipStatement(clear, githubService) // verified statement ) yield { proof } 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/verifiers/WebsiteFileVerifier.scala index 04cad1e..12d2d73 100644 --- a/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verification/WebsiteFileVerifier.scala +++ b/gpg/skeybase/src/main/scala/com/github/jodersky/skeybase/verifiers/WebsiteFileVerifier.scala @@ -1,12 +1,7 @@ package com.github.jodersky.skeybase -package verification +package verifiers import scala.concurrent.Future - - -import Verifier.extractSignedStatement -import Verifier.finalHost -import Verifier.verifyStatement import Verifier.withRedirects import akka.actor.ActorSystem import openpgp.Backend @@ -24,12 +19,19 @@ class WebsiteFileVerifier(backend: Backend) extends Verifier { def verify(fingerprint: String, proof: Proof)(implicit sys: ActorSystem) = { import sys.dispatcher - val pipeline = withRedirects(sendReceive) ~> finalHost(proof.nametag).tupled ~> unmarshal[String] + val pipeline = withRedirects(sendReceive) ~> finalHost(proof.nametag) ~> unmarshal[String] + + val service = Service( + name = None, + username = None, + hostname = Some(proof.nametag), + domain = None) + for ( content <- pipeline(Get(proof.proofUrl)); - signed <- extractSignedStatement(content); + signed <- extractSignedMessage(content); clear <- backend.verifySignature(signed, fingerprint); - verified <- verifyStatement(clear, "github", proof.nametag) + verified <- verifyOwnershipStatement(clear, service) ) yield { proof } |