From 54a9b3189a04c62652486ba01d87b951ecf2b953 Mon Sep 17 00:00:00 2001 From: Ignasi Marimon-Clos Date: Sun, 17 May 2020 00:09:24 +0200 Subject: [PATCH] Adds support to read PEM keys (#29039) Co-Authored-By: James Roper --- .../akka/pki/pem/DERPrivateKeyLoader.scala | 130 ++++++++++++++++++ .../main/scala/akka/pki/pem/PEMDecoder.scala | 76 ++++++++++ akka-pki/src/test/resources/certificate.pem | 19 +++ .../src/test/resources/multi-prime-pkcs1.pem | 28 ++++ akka-pki/src/test/resources/pkcs1.pem | 27 ++++ akka-pki/src/test/resources/pkcs8.pem | 28 ++++ .../pki/pem/DERPrivateKeyLoaderSpec.scala | 54 ++++++++ .../scala/akka/pki/pem/PEMDecoderSpec.scala | 93 +++++++++++++ build.sbt | 18 ++- project/Dependencies.scala | 9 ++ 10 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 akka-pki/src/main/scala/akka/pki/pem/DERPrivateKeyLoader.scala create mode 100644 akka-pki/src/main/scala/akka/pki/pem/PEMDecoder.scala create mode 100644 akka-pki/src/test/resources/certificate.pem create mode 100644 akka-pki/src/test/resources/multi-prime-pkcs1.pem create mode 100644 akka-pki/src/test/resources/pkcs1.pem create mode 100644 akka-pki/src/test/resources/pkcs8.pem create mode 100644 akka-pki/src/test/scala/akka/pki/pem/DERPrivateKeyLoaderSpec.scala create mode 100644 akka-pki/src/test/scala/akka/pki/pem/PEMDecoderSpec.scala diff --git a/akka-pki/src/main/scala/akka/pki/pem/DERPrivateKeyLoader.scala b/akka-pki/src/main/scala/akka/pki/pem/DERPrivateKeyLoader.scala new file mode 100644 index 0000000000..68f3f58b3e --- /dev/null +++ b/akka-pki/src/main/scala/akka/pki/pem/DERPrivateKeyLoader.scala @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2020 Lightbend Inc. + */ + +package akka.pki.pem + +import java.math.BigInteger +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAMultiPrimePrivateCrtKeySpec +import java.security.spec.RSAOtherPrimeInfo +import java.security.spec.RSAPrivateCrtKeySpec + +import akka.annotation.ApiMayChange +import akka.pki.pem.PEMDecoder.DERData +import com.hierynomus.asn1.ASN1InputStream +import com.hierynomus.asn1.encodingrules.der.DERDecoder +import com.hierynomus.asn1.types.constructed.ASN1Sequence +import com.hierynomus.asn1.types.primitive.ASN1Integer + +final class PEMLoadingException(message: String, cause: Throwable) extends RuntimeException(message, cause) { + def this(msg: String) = this(msg, null) +} + +object DERPrivateKeyLoader { + + /** + * Converts the DER payload in [[PEMDecoder.DERData]] into a [[java.security.PrivateKey]]. The received DER + * data must be a valid PKCS#1 (identified in PEM as "RSA PRIVATE KEY") or non-ecnrypted PKCS#8 (identified + * in PEM as "PRIVATE KEY"). + * @throws PEMLoadingException when the `derData` is for an unsupported format + */ + @ApiMayChange + @throws[PEMLoadingException]("when the `derData` is for an unsupported format") + def load(derData: DERData): PrivateKey = { + derData.label match { + case "RSA PRIVATE KEY" => + loadPkcs1PrivateKey(derData.bytes) + case "PRIVATE KEY" => + loadPkcs8PrivateKey(derData.bytes) + case unknown => + throw new PEMLoadingException(s"Don't know how to read a private key from PEM data with label [$unknown]") + } + } + + private def loadPkcs1PrivateKey(bytes: Array[Byte]) = { + val derInputStream = new ASN1InputStream(new DERDecoder, bytes) + // Here's the specification: https://tools.ietf.org/html/rfc3447#appendix-A.1.2 + val sequence = { + try { + derInputStream.readObject[ASN1Sequence]() + } finally { + derInputStream.close() + } + } + val version = getInteger(sequence, 0, "version").intValueExact() + if (version < 0 || version > 1) { + throw new IllegalArgumentException(s"Unsupported PKCS1 version: $version") + } + val modulus = getInteger(sequence, 1, "modulus") + val publicExponent = getInteger(sequence, 2, "publicExponent") + val privateExponent = getInteger(sequence, 3, "privateExponent") + val prime1 = getInteger(sequence, 4, "prime1") + val prime2 = getInteger(sequence, 5, "prime2") + val exponent1 = getInteger(sequence, 6, "exponent1") + val exponent2 = getInteger(sequence, 7, "exponent2") + val coefficient = getInteger(sequence, 8, "coefficient") + + val keySpec = if (version == 0) { + new RSAPrivateCrtKeySpec( + modulus, + publicExponent, + privateExponent, + prime1, + prime2, + exponent1, + exponent2, + coefficient) + } else { + // Does anyone even use multi-primes? Who knows, maybe this code will never be used. Anyway, I guess it will work, + // the spec isn't exactly complicated. + val otherPrimeInfosSequence = getSequence(sequence, 9, "otherPrimeInfos") + val otherPrimeInfos = (for (i <- 0 until otherPrimeInfosSequence.size()) yield { + val name = s"otherPrimeInfos[$i]" + val seq = getSequence(otherPrimeInfosSequence, i, name) + val prime = getInteger(seq, 0, s"$name.prime") + val exponent = getInteger(seq, 1, s"$name.exponent") + val coefficient = getInteger(seq, 2, s"$name.coefficient") + new RSAOtherPrimeInfo(prime, exponent, coefficient) + }).toArray + new RSAMultiPrimePrivateCrtKeySpec( + modulus, + publicExponent, + privateExponent, + prime1, + prime2, + exponent1, + exponent2, + coefficient, + otherPrimeInfos) + } + + val keyFactory = KeyFactory.getInstance("RSA") + keyFactory.generatePrivate(keySpec) + } + + private def getInteger(sequence: ASN1Sequence, index: Int, name: String): BigInteger = { + sequence.get(index) match { + case integer: ASN1Integer => integer.getValue + case other => + throw new IllegalArgumentException(s"Expected integer tag for $name at index $index, but got: ${other.getTag}") + } + } + + private def getSequence(sequence: ASN1Sequence, index: Int, name: String): ASN1Sequence = { + sequence.get(index) match { + case seq: ASN1Sequence => seq + case other => + throw new IllegalArgumentException(s"Expected sequence tag for $name at index $index, but got: ${other.getTag}") + } + } + + private def loadPkcs8PrivateKey(bytes: Array[Byte]) = { + val keySpec = new PKCS8EncodedKeySpec(bytes) + val keyFactory = KeyFactory.getInstance("RSA") + keyFactory.generatePrivate(keySpec) + } + +} diff --git a/akka-pki/src/main/scala/akka/pki/pem/PEMDecoder.scala b/akka-pki/src/main/scala/akka/pki/pem/PEMDecoder.scala new file mode 100644 index 0000000000..1f6023a9ed --- /dev/null +++ b/akka-pki/src/main/scala/akka/pki/pem/PEMDecoder.scala @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2020 Lightbend Inc. + */ + +package akka.pki.pem + +import java.util.Base64 + +import akka.annotation.ApiMayChange + +/** + * Decodes lax PEM encoded data, according to + * + * https://tools.ietf.org/html/rfc7468 + */ +object PEMDecoder { + + // I believe this regex matches the RFC7468 Lax ABNF semantics jkhdft exactly. + private val PEMRegex = { + // Luckily, Java Pattern's \s matches the RFCs W ABNF expression perfectly + // (space, tab, carriage return, line feed, form feed, vertical tab) + + // The variables here are named to match the expressions in the RFC7468 ABNF + // description. The content of the regex may not match the structure of the + // expression because sometimes there are nicer way to do things in regexes. + + // All printable ASCII characters minus hyphen + val labelchar = """[\p{Print}&&[^-]]""" + // Starts and finishes with a labelchar, with as many label chars and hyphens or + // spaces in between, but no double spaces or hyphens, also may be empty. + val label = raw"""(?:$labelchar(?:[\- ]?$labelchar)*)?""" + // capturing group so we can extract the label + val preeb = raw"""-----BEGIN ($label)-----""" + // we don't extract the end label because the RFC says we can ignore it (it + // doesn't have to match the begin label) + val posteb = raw"""-----END $label-----""" + // Any of the base64 chars (alphanum, +, /) and whitespace, followed by at most 2 + // padding characters, separated by zero to many whitespace characters + val laxbase64text = """[A-Za-z0-9\+/\s]*(?:=\s*){0,2}""" + + val laxtextualmessage = raw"""\s*$preeb($laxbase64text)$posteb\s*""" + + laxtextualmessage.r + } + + /** + * Decodes a PEM String into an identifier and the DER bytes of the content. + * + * See https://tools.ietf.org/html/rfc7468 and https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail + * + * @param pemData the PEM data (pre-eb, base64-MIME data and ponst-eb) + * @return the decoded bytes and the content type. + */ + @throws[PEMLoadingException]( + "If the `pemData` is not valid PEM format (according to https://tools.ietf.org/html/rfc7468).") + @ApiMayChange + def decode(pemData: String): DERData = { + pemData match { + case PEMRegex(label, base64) => + try { + new DERData(label, Base64.getMimeDecoder.decode(base64)) + } catch { + case iae: IllegalArgumentException => + throw new PEMLoadingException( + s"Error decoding base64 data from PEM data (note: expected MIME-formatted Base64)", + iae) + } + + case _ => throw new PEMLoadingException("Not a PEM encoded data.") + } + } + + @ApiMayChange + final class DERData(val label: String, val bytes: Array[Byte]) + +} diff --git a/akka-pki/src/test/resources/certificate.pem b/akka-pki/src/test/resources/certificate.pem new file mode 100644 index 0000000000..4fb0ea7b86 --- /dev/null +++ b/akka-pki/src/test/resources/certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIQfEHPfR1p1xuW9TQlfxAugjANBgkqhkiG9w0BAQsFADAv +MS0wKwYDVQQDEyQwZDIwN2I2OC05YTIwLTRlZTgtOTJjYi1iZjk2OTk1ODFjZjgw +HhcNMTkxMDExMTMyODUzWhcNMjQxMDA5MTQyODUzWjAvMS0wKwYDVQQDEyQwZDIw +N2I2OC05YTIwLTRlZTgtOTJjYi1iZjk2OTk1ODFjZjgwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDhD0BxlDzEOzcp45lPHL60lnM6k3puEGb2lKHL5/nR +F94FCnZL0FH8EdxWzzAYgys+kUwSdo4QMuWuvjY2Km4Wob6k4uAeYEFTCfBdi4/z +r4kpWzu8xLz+uZWimLQrjqVytNNK3DMv6ebWUJ/92VTDS4yzWk4YV0MVr2b2OgMK +SgMvaFQ8L/CwyML72PBWIqU67+MMvvcTLxQdyEgQTTjP0bbiXMLDvfZDarLJojsW +SNBz7AIkznhGkzIGGdhAa41PnPu9XaBFhaqx9Qe3+MG2/k1l/46eHtmxCqhOUde1 +i0vy6ZfgcGifua1tg1UBI/oT4S0dsq24dq7K1MYLyHTrAgMBAAGjIzAhMA4GA1Ud +DwEB/wQEAwICBDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAa +5YOlvob4wqps3HLaOIi7VzDihNHciP+BI0mzxHa7D3bGaecRPSeG3xEoD/Uxs9o4 +8cByPpYx1Wl8LLRx14wDcK0H+UPpo4gCI6h6q92cJj0YRjcSUUt8EIu3qnFMjtM+ +sl/uc21fGlq6cvMRZXqtVYENoSNTDHi5a5eEXRa2eZ8XntjvOanOhIKWmxvr8r4a +Voz4WdnXx1C8/BzB62UBoMu4QqMGMLk5wXP0D6hECUuespMest+BeoJAVhTq7wZs +rSP9q08n1stZFF4+bEBaxcqIqdhOLQdHcYELN+a5v0Mcwdsy7jJMagmNPfsKoOKC +hLOsmNYKHdmWg37Jib5o +-----END CERTIFICATE----- \ No newline at end of file diff --git a/akka-pki/src/test/resources/multi-prime-pkcs1.pem b/akka-pki/src/test/resources/multi-prime-pkcs1.pem new file mode 100644 index 0000000000..2ad888e5b6 --- /dev/null +++ b/akka-pki/src/test/resources/multi-prime-pkcs1.pem @@ -0,0 +1,28 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIE2AIBAQKCAQEAqFD3rwpvkqgkxSCIKyO2M///6CnJz/YqYycSxeUXJSeC+sf0 +Svu4mzSNyx9mglH6ubVJ1x01XlK7GDCAOuqi7Dgay23m+qRq+MF89gYZY9YuVPBr +jsFPK3XguIOLTIV2VCdskBLbW0G6b6VVDOVLCffD6fRuy/H43BI2l9+nuTAdYpCE +sX7IliRi2HR09nv1THSMrdrcq48EdLK+Qzj3f+9gedqeJP7ABY29eIBAOasUY356 +So/2dqwhfmMbXswHqIFVQIBd+y1F4Gwf4HxTN06bHhs5rOg4fAREmr1SU25CnhDA +SBw8c5zRFNGgekkzIxSZLFw27ezYGxZyIjnKawIDAQABAoIBAFrcKniRV52BqyfG +4fr3sjnr7gcz17+tkUApLZcqjg3+gFREcHmx3PvbqNeHwdyDyKdLV+sJ129tlZX/ +SJmFZCHEP6KlV1TiQOS7/msI69fbHPO5PTaCjTzkbA4WOAgv+M4XjR82RM1EusO1 +tPegWfLhj5dvdAfgTpodW1XDFs3QHoQbKkLLMREOb3j+LuK38npxN1UmtDNSRlII +xv77KywOij0LMG4CjIeXmjGdL9BWzlbHv5Zk0wuWHtGFvkoQO6EPn3NVb1uY1ZdX +IekdvA71qssliVFi30/3ZiFTwt3fXfNBawN7t1s0pJlsPAOz7iiucHWrUCvtaXD+ +miFFN4ECVgez3BQqe6o3lmHG7LQKy/RWBfDXLCc1JziipTeu+e1piNd7WnJcpsbl +31kTA3JBp7VU41EEXEBtWz8cW3I9y6AhukAyeWBnww9FSet8bKGAuYj1siCXAlYH +L+pEeS8NuGO1iigEEZzZrOMC3Yg9/evwhjXxbtwst9NewE22UdJGB7tm/2v7osaE +/DVQk4xrNe/AEDRcQf73XXEEq/wsoJjHp/Be8lvH6bLHpx0rBQJWA6Lxy3+HJQTb +dcwx4oqaYf/fDCa6TMR2gPGg6RouGt1RYtJ1EDsX7h2HBgnI/b9ri1vJem2BlFVM +2A3uSpBMO4zQ3xA1Z8N1PxdqX8g9jT412iiNcX8CVWC3atQH8iuwKhnSEqyuVw7s +e/kTMVcIsQMNENzn+/3JwaBQNXZFnGLJqhdhIMAkuVbcopiu9+/E4462gecCNRSX +XUyBHANxJIWicAtDYdXaDP6u3NUCVgSPOp+63cxSzMo7FLsYIAFH14VFEXKaw9s9 +JbMI4+dIUMfgs2fvCK6ibFpIrhESv4kaQ+uRjkTjyGv+OkKvP5jxoDUjKRA4WcLU +OHH2wrajfrFJ0sfhMIIBDDCCAQgCVgMKWFv+kAwf55zhJjiP/+dSyL7wnEFw5gcw +F8tBg1M7LR4BgTn+j/p/8qEg4scL0eDdDcxGNPaMNlYAtFjO56Qv/oAHJ7EdwfYR +knMUDxYZV2LU+aGpAlYCMhBIrnWbK9b3xOby5ZnolDF/IQXVhA+4lRQ5pR+OlScp +ifClzpxuSsMNdFAPaQuwlDEImJJakDoUtQGHODKysC3ailAxaMnORjY5f/y8+qPO +LPnvsQJWAYm7meCYk9a89TGWQfFl4l1W1dYlI/4FDYOirOvFd+/LICMk+9E43Qff +lqeAL45/QoXNEDpSVNy507ggtBbxqcEehjspYGqp76NRTvRMKgK9/XH4u2E= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/akka-pki/src/test/resources/pkcs1.pem b/akka-pki/src/test/resources/pkcs1.pem new file mode 100644 index 0000000000..5f23b81867 --- /dev/null +++ b/akka-pki/src/test/resources/pkcs1.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEA0IQFs9KpS6fpm7Bq5JcAzRzdZnYub7qGBJ9+QZX8F6qUYiXf +jbVPAZSIksNg6G5yMkzBLZ8r0UOm5WJs6sAHKGJOQk9DcwZEt3XpGbYXAnM5V1sb +xd5oNcbXLcHouU8jrEj+O2KvmgCzDDCUOf3SjGnF4dWqhsTT9tMJZvWVB0OjpKnT +zcoxFKO/XCz8Spb1+FgKUgt3afA+JTRpQWtaZJ41fuTg0rm0qtUSeZXPUEYkqqK4 +msoSe7dbu7uiEOkUPoeiP7wzSrwihQSCxOzwdphV2XtF5Xfs24/Ad4WKXTqRVUK/ +yldcQy2orFSsX41KBzS8PI1hnOC6uRA0PiGd/QIDAQABAoIBAQCDbxShGuK326m3 +B2b5m+1XXSB5m3j92FbtxxMwiDgVOuK5UyItEuIwHs5PpHQLTsMQzaze8vwNtlUX +Ngltl4lrfTvTNF9Ru9vIwLwkBtFOLA8y7yz8doq9iw7LuvTVCft0d7Y4/KWvr00t +G9nzC/mRpIKlLaeFt7/cT34XtikwH+Ez8uWGidYnrqkKZ0bsIZvD+e7gae+veohN +BnTcyDIk8P5nXG0vM4hxtjLo3KstemwOt6vCtiKzL2Vq/JAVD3nlec8WPBzft79I +k5tb3Qm/OnxIQaWF5AhAVkXgMsLL3ddJoAn/K6NZ6NtRGZozkwdP+m4nacrKFJVJ +6ew7OdAJAoGBAO65GvteHLXQ3d1NfU+X6RSBatJdpuf2S4Hc7P+sTkPjLDlQPDKL +ZFLVigPlCehMvgGASPOxL8UqPZugwEo83ywp5t/iOUccXZCPSISreSzWJPfOJ+dl +aKP2cSHHPNC/mISDpp/NF+xfgEAUQQ6AdOKwHGlsFWBvkkw810d4zmXvAoGBAN+b +QYv32wNa2IHC1Ri3VgtfHpQ20fMz+UO6TMefBbgkcgBk4L+O7gSCpThaqy9R7UnX +IEH0QyaBEXzQxB7qvCo1pczWiEUcG+vAyyz9U9oO9Hee/UTMOWL4Sj7qoavau2Be +5PFOO6qA+19JTnStuNb3swNrMmxDQpyNDvUkYAbTAoGBALuYkSCJ84vZaBBZrajX +mt13WieYWuocPXf+0euVTyfAJOehKr0ZlywVDNFEssVvUT1Cv5FpYz3QlPtwlsuA +DGzbPMghMZu1Kb3JK1a+nYnjeseVpPwNT+7RYlQGCr+MYOF5x336oNsqrVEt2XX4 +8mGVva4GtsHCy7fHc/GBeMjXAoGBALhEYkytER//okG0xBUdKFwwo6tyTavEndpx +UUqDwpvP9N5cQ1W4vG6dFviMx1s0gX4DOQMA/sFhRX79L1FnEW8bTKmz9RI2qs+p +zgUiMhKVlmJpc79ZKMVlZRHaGybbFuTA7pvoY4ULy5rndy7x5kvITg44LZJID0Gh +gL0Fn9ifAoGAEaWA7yxTA7phDIt0U91HZEVhNSM4cZDurE7614qWhKveSP8f0J4c +3d9y/re4RcCwmss/FtQWSkB+32WbD3qk+QB7hV1oFqJ5ObcfwevGYR8m6vOz1h2L +3pQNi4PcH3U8eeGG1laFKUQ295rBLNqIbOo2y+4hxMnC4tka1X118Ec= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/akka-pki/src/test/resources/pkcs8.pem b/akka-pki/src/test/resources/pkcs8.pem new file mode 100644 index 0000000000..bd63aa1dc0 --- /dev/null +++ b/akka-pki/src/test/resources/pkcs8.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDQhAWz0qlLp+mb +sGrklwDNHN1mdi5vuoYEn35BlfwXqpRiJd+NtU8BlIiSw2DobnIyTMEtnyvRQ6bl +YmzqwAcoYk5CT0NzBkS3dekZthcCczlXWxvF3mg1xtctwei5TyOsSP47Yq+aALMM +MJQ5/dKMacXh1aqGxNP20wlm9ZUHQ6OkqdPNyjEUo79cLPxKlvX4WApSC3dp8D4l +NGlBa1pknjV+5ODSubSq1RJ5lc9QRiSqoriayhJ7t1u7u6IQ6RQ+h6I/vDNKvCKF +BILE7PB2mFXZe0Xld+zbj8B3hYpdOpFVQr/KV1xDLaisVKxfjUoHNLw8jWGc4Lq5 +EDQ+IZ39AgMBAAECggEBAINvFKEa4rfbqbcHZvmb7VddIHmbeP3YVu3HEzCIOBU6 +4rlTIi0S4jAezk+kdAtOwxDNrN7y/A22VRc2CW2XiWt9O9M0X1G728jAvCQG0U4s +DzLvLPx2ir2LDsu69NUJ+3R3tjj8pa+vTS0b2fML+ZGkgqUtp4W3v9xPfhe2KTAf +4TPy5YaJ1ieuqQpnRuwhm8P57uBp7696iE0GdNzIMiTw/mdcbS8ziHG2Mujcqy16 +bA63q8K2IrMvZWr8kBUPeeV5zxY8HN+3v0iTm1vdCb86fEhBpYXkCEBWReAywsvd +10mgCf8ro1no21EZmjOTB0/6bidpysoUlUnp7Ds50AkCgYEA7rka+14ctdDd3U19 +T5fpFIFq0l2m5/ZLgdzs/6xOQ+MsOVA8MotkUtWKA+UJ6Ey+AYBI87EvxSo9m6DA +SjzfLCnm3+I5RxxdkI9IhKt5LNYk984n52Voo/ZxIcc80L+YhIOmn80X7F+AQBRB +DoB04rAcaWwVYG+STDzXR3jOZe8CgYEA35tBi/fbA1rYgcLVGLdWC18elDbR8zP5 +Q7pMx58FuCRyAGTgv47uBIKlOFqrL1HtSdcgQfRDJoERfNDEHuq8KjWlzNaIRRwb +68DLLP1T2g70d579RMw5YvhKPuqhq9q7YF7k8U47qoD7X0lOdK241vezA2sybENC +nI0O9SRgBtMCgYEAu5iRIInzi9loEFmtqNea3XdaJ5ha6hw9d/7R65VPJ8Ak56Eq +vRmXLBUM0USyxW9RPUK/kWljPdCU+3CWy4AMbNs8yCExm7UpvckrVr6dieN6x5Wk +/A1P7tFiVAYKv4xg4XnHffqg2yqtUS3ZdfjyYZW9rga2wcLLt8dz8YF4yNcCgYEA +uERiTK0RH/+iQbTEFR0oXDCjq3JNq8Sd2nFRSoPCm8/03lxDVbi8bp0W+IzHWzSB +fgM5AwD+wWFFfv0vUWcRbxtMqbP1Ejaqz6nOBSIyEpWWYmlzv1koxWVlEdobJtsW +5MDum+hjhQvLmud3LvHmS8hODjgtkkgPQaGAvQWf2J8CgYARpYDvLFMDumEMi3RT +3UdkRWE1IzhxkO6sTvrXipaEq95I/x/Qnhzd33L+t7hFwLCayz8W1BZKQH7fZZsP +eqT5AHuFXWgWonk5tx/B68ZhHybq87PWHYvelA2Lg9wfdTx54YbWVoUpRDb3msEs +2ohs6jbL7iHEycLi2RrVfXXwRw== +-----END PRIVATE KEY----- diff --git a/akka-pki/src/test/scala/akka/pki/pem/DERPrivateKeyLoaderSpec.scala b/akka-pki/src/test/scala/akka/pki/pem/DERPrivateKeyLoaderSpec.scala new file mode 100644 index 0000000000..c10eb11930 --- /dev/null +++ b/akka-pki/src/test/scala/akka/pki/pem/DERPrivateKeyLoaderSpec.scala @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020 Lightbend Inc. + */ + +package akka.pki.pem + +import java.io.File +import java.nio.charset.Charset +import java.nio.file.Files +import java.security.PrivateKey + +import org.scalatest.EitherValues +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class DERPrivateKeyLoaderSpec extends AnyWordSpec with Matchers with EitherValues { + + "The DER Private Key loader" should { + "decode the same key in PKCS#1 and PKCS#8 formats" in { + val pkcs1 = load("pkcs1.pem") + val pkcs8 = load("pkcs8.pem") + pkcs1 should ===(pkcs8) + } + + "parse multi primes" in { + load("multi-prime-pkcs1.pem") + // Not much we can verify here - I actually think the default JDK security implementation ignores the extra + // primes, and it fails to parse a multi-prime PKCS#8 key. + } + + "fail on unsupported PEM contents (Certificates are not private keys)" in { + assertThrows[PEMLoadingException] { + load("certificate.pem") + } + } + + } + + private def load(resource: String): PrivateKey = { + val derData: PEMDecoder.DERData = loadDerData(resource) + DERPrivateKeyLoader.load(derData) + } + + private def loadDerData(resource: String) = { + val resourceUrl = getClass.getClassLoader.getResource(resource) + resourceUrl.getProtocol should ===("file") + val path = new File(resourceUrl.toURI).toPath + val bytes = Files.readAllBytes(path) + val str = new String(bytes, Charset.forName("UTF-8")) + val derData = PEMDecoder.decode(str) + derData + } + +} diff --git a/akka-pki/src/test/scala/akka/pki/pem/PEMDecoderSpec.scala b/akka-pki/src/test/scala/akka/pki/pem/PEMDecoderSpec.scala new file mode 100644 index 0000000000..6716e782e0 --- /dev/null +++ b/akka-pki/src/test/scala/akka/pki/pem/PEMDecoderSpec.scala @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2020 Lightbend Inc. + */ + +package akka.pki.pem + +import java.util.Base64 + +import org.scalatest.EitherValues +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class PEMDecoderSpec extends AnyWordSpec with Matchers with EitherValues { + + private val cert = + """-----BEGIN CERTIFICATE----- + |MIIDCzCCAfOgAwIBAgIQfEHPfR1p1xuW9TQlfxAugjANBgkqhkiG9w0BAQsFADAv + |MS0wKwYDVQQDEyQwZDIwN2I2OC05YTIwLTRlZTgtOTJjYi1iZjk2OTk1ODFjZjgw + |HhcNMTkxMDExMTMyODUzWhcNMjQxMDA5MTQyODUzWjAvMS0wKwYDVQQDEyQwZDIw + |N2I2OC05YTIwLTRlZTgtOTJjYi1iZjk2OTk1ODFjZjgwggEiMA0GCSqGSIb3DQEB + |AQUAA4IBDwAwggEKAoIBAQDhD0BxlDzEOzcp45lPHL60lnM6k3puEGb2lKHL5/nR + |F94FCnZL0FH8EdxWzzAYgys+kUwSdo4QMuWuvjY2Km4Wob6k4uAeYEFTCfBdi4/z + |r4kpWzu8xLz+uZWimLQrjqVytNNK3DMv6ebWUJ/92VTDS4yzWk4YV0MVr2b2OgMK + |SgMvaFQ8L/CwyML72PBWIqU67+MMvvcTLxQdyEgQTTjP0bbiXMLDvfZDarLJojsW + |SNBz7AIkznhGkzIGGdhAa41PnPu9XaBFhaqx9Qe3+MG2/k1l/46eHtmxCqhOUde1 + |i0vy6ZfgcGifua1tg1UBI/oT4S0dsq24dq7K1MYLyHTrAgMBAAGjIzAhMA4GA1Ud + |DwEB/wQEAwICBDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAa + |5YOlvob4wqps3HLaOIi7VzDihNHciP+BI0mzxHa7D3bGaecRPSeG3xEoD/Uxs9o4 + |8cByPpYx1Wl8LLRx14wDcK0H+UPpo4gCI6h6q92cJj0YRjcSUUt8EIu3qnFMjtM+ + |sl/uc21fGlq6cvMRZXqtVYENoSNTDHi5a5eEXRa2eZ8XntjvOanOhIKWmxvr8r4a + |Voz4WdnXx1C8/BzB62UBoMu4QqMGMLk5wXP0D6hECUuespMest+BeoJAVhTq7wZs + |rSP9q08n1stZFF4+bEBaxcqIqdhOLQdHcYELN+a5v0Mcwdsy7jJMagmNPfsKoOKC + |hLOsmNYKHdmWg37Jib5o + |-----END CERTIFICATE-----""".stripMargin + + "The PEM decoder" should { + "decode a real world certificate" in { + PEMDecoder.decode(cert).label should ===("CERTIFICATE") + } + + "decode data with no spaces" in { + val result = PEMDecoder.decode( + "-----BEGIN FOO-----" + Base64.getEncoder.encodeToString("abc".getBytes()) + "-----END FOO-----") + result.label should ===("FOO") + new String(result.bytes) should ===("abc") + } + + "decode data with lots of spaces" in { + val result = PEMDecoder.decode( + "\n \t \r -----BEGIN FOO-----\n" + + Base64.getEncoder.encodeToString("abc".getBytes()).flatMap(c => s"$c\n\r \t\n") + "-----END FOO-----\n \t \r ") + result.label should ===("FOO") + new String(result.bytes) should ===("abc") + } + + "decode data with two padding characters" in { + // A 4 byte input results in a 6 character output with 2 padding characters + val result = PEMDecoder.decode( + "-----BEGIN FOO-----" + Base64.getEncoder.encodeToString("abcd".getBytes()) + "-----END FOO-----") + result.label should ===("FOO") + new String(result.bytes) should ===("abcd") + } + + "decode data with one padding character" in { + // A 5 byte input results in a 7 character output with 1 padding character1 + val result = PEMDecoder.decode( + "-----BEGIN FOO-----" + Base64.getEncoder.encodeToString("abcde".getBytes()) + "-----END FOO-----") + result.label should ===("FOO") + new String(result.bytes) should ===("abcde") + } + + "fail decode when the format is wrong (not MIME BASE64, lines too long)" in { + val input = """-----BEGIN CERTIFICATE----- + |MIIDCzCCAfOgAwIBAgIQfEHPfR1p1xuW9TQlfxAugjANBgkqhkiG9w0BAQsFADAviZjk2OTk1ODFjZjgw + |HhcNMTkxMDExMTMyODUzWhcNMjQxMDA5MTQyODUzWjAvMS0wKwYDVQQDEyQwZDIwhLOsmNYKHdmWg37Jib5o + |-----END CERTIFICATE-----""".stripMargin + + assertThrows[PEMLoadingException] { + PEMDecoder.decode(input) + } + } + + "fail decode when the format is wrong (not PEM, invalid per/post-EB)" in { + val input = cert.replace("BEGIN", "BGN").replace("END ", "GLGLGL ") + + assertThrows[PEMLoadingException] { + PEMDecoder.decode(input) + } + } + + } + +} diff --git a/build.sbt b/build.sbt index 27e63fb5ee..11036c854d 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -import akka.{AutomaticModuleName, CopyrightHeaderForBuild, Paradox, ScalafixIgnoreFilePlugin} +import akka.{ AutomaticModuleName, CopyrightHeaderForBuild, Paradox, ScalafixIgnoreFilePlugin } enablePlugins( UnidocRoot, @@ -11,17 +11,18 @@ enablePlugins( disablePlugins(MimaPlugin) addCommandAlias( name = "fixall", - value = ";scalafixEnable;compile:scalafix;test:scalafix;multi-jvm:scalafix;scalafmtAll;test:compile;multi-jvm:compile;reload") + value = + ";scalafixEnable;compile:scalafix;test:scalafix;multi-jvm:scalafix;scalafmtAll;test:compile;multi-jvm:compile;reload") addCommandAlias( name = "sortImports", value = ";scalafixEnable;compile:scalafix SortImports;test:scalafix SortImports;scalafmtAll") import akka.AkkaBuild._ -import akka.{AkkaBuild, Dependencies, OSGi, Protobuf, SigarLoader, VersionGenerator} +import akka.{ AkkaBuild, Dependencies, OSGi, Protobuf, SigarLoader, VersionGenerator } import com.typesafe.sbt.SbtMultiJvm.MultiJvmKeys.MultiJvm import com.typesafe.tools.mima.plugin.MimaPlugin -import sbt.Keys.{initialCommands, parallelExecution} +import sbt.Keys.{ initialCommands, parallelExecution } import spray.boilerplate.BoilerplatePlugin initialize := { @@ -67,6 +68,7 @@ lazy val aggregatedProjects: Seq[ProjectReference] = List[ProjectReference]( persistenceTestkit, protobuf, protobufV3, + pki, remote, remoteTests, slf4j, @@ -313,6 +315,14 @@ lazy val protobufV3 = akkaModule("akka-protobuf-v3") test in assembly := {}, // assembly runs tests for unknown reason which introduces another cyclic dependency to packageBin via exportedJars description := "Akka Protobuf V3 is a shaded version of the protobuf runtime. Original POM: https://github.com/protocolbuffers/protobuf/blob/v3.9.0/java/pom.xml") +lazy val pki = + akkaModule("akka-pki") + .dependsOn(actor) // this dependency only exists for "@ApiMayChange" + .settings(Dependencies.pki) + .settings(AutomaticModuleName.settings("akka.pki")) + // The akka-pki artifact was added in Akka 2.6.2, no MiMa checks yet. + .disablePlugins(MimaPlugin) + lazy val remote = akkaModule("akka-remote") .dependsOn( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 8b90ff20ad..0ed39976d8 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -83,6 +83,8 @@ object Dependencies { // Added explicitly for when artery tcp is used val agrona = "org.agrona" % "agrona" % agronaVersion // ApacheV2 + val asnOne = "com.hierynomus" % "asn-one" % "0.4.0" // ApacheV2 + val jacksonCore = "com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion // ApacheV2 val jacksonAnnotations = "com.fasterxml.jackson.core" % "jackson-annotations" % jacksonVersion // ApacheV2 val jacksonDatabind = "com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion // ApacheV2 @@ -195,6 +197,13 @@ object Dependencies { val actorTestkitTyped = l ++= Seq(Provided.logback, Provided.junit, Provided.scalatest, Test.scalatestJUnit) + val pki = l ++= + Seq( + asnOne, + // pull up slf4j version from the one provided transitively in asnOne to fix unidoc + Compile.slf4jApi % "provided", + Test.scalatest) + val remoteDependencies = Seq(netty, aeronDriver, aeronClient) val remoteOptionalDependencies = remoteDependencies.map(_ % "optional")