Skip to content
This repository was archived by the owner on Aug 19, 2023. It is now read-only.

Commit 5ebe5a8

Browse files
committed
First public commit
0 parents  commit 5ebe5a8

File tree

9 files changed

+455
-0
lines changed

9 files changed

+455
-0
lines changed

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
*.swp
2+
.idea
3+
*.iml
4+
*~
5+
target/
6+
.metals
7+
metals.sbt
8+
.bsp
9+
null/
10+
.bloop
11+
.vscode

.scalafmt.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
maxColumn = 80
2+
align = more
3+
rewrite.rules = [RedundantBraces]

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Let's Encrypt Scala!
2+
3+
Documentation: **https://www.scalawilliam.com/letsencrypt-scala/**

build.sbt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/** Names */
2+
organization := "com.scalawilliam"
3+
name := "letsencrypt-scala"
4+
5+
/** Versions */
6+
version := "0.0.2-SNAPSHOT"
7+
versionScheme := Some("semver-spec")
8+
scalaVersion := "2.13.5"
9+
crossScalaVersions := Seq("2.13.5", "3.0.0-RC2")
10+
scalacOptions := Nil
11+
12+
/** Publishing: currently to Sonatype snapshots */
13+
publishTo := {
14+
val nexus = "https://oss.sonatype.org/"
15+
// if (isSnapshot.value)
16+
Some("snapshots" at nexus + "content/repositories/snapshots")
17+
// else
18+
// Some("releases" at nexus + "service/local/staging/deploy/maven2")
19+
}
20+
21+
/** Dependencies */
22+
libraryDependencies ++= Seq(
23+
"org.scalatest" %% "scalatest" % "3.2.7" % Test, {
24+
val sv = scalaVersion.value
25+
"org.typelevel" %% "cats-effect" % (if (sv.startsWith("3"))
26+
"3.0.1"
27+
else "2.4.1")
28+
},
29+
"org.bouncycastle" % "bcprov-jdk16" % "1.46"
30+
)
31+
32+
/** Metadata */
33+
homepage := Some(url("https://www.scalawilliam.com/letsencrypt-scala/"))
34+
35+
licenses := List(
36+
"Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0"))
37+
38+
developers := List(
39+
Developer(
40+
"ScalaWilliam",
41+
"ScalaWilliam",
42+
43+
url("https://www.scalawilliam.com")
44+
)
45+
)

project/build.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sbt.version=1.5.0-RC2

project/plugins.sbt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
* Copyright 2021 ScalaWilliam
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.scalawilliam.letsencrypt
18+
19+
import cats.effect.{Async, Resource, Sync}
20+
import cats.implicits._
21+
import com.scalawilliam.letsencrypt.LetsEncryptScala.{
22+
CertificateAliasPrefix,
23+
PrivateKeyAlias
24+
}
25+
import com.scalawilliam.letsencrypt.LetsEncryptScalaUtils._
26+
27+
import java.io.ByteArrayInputStream
28+
import java.nio.file.{Path, Paths}
29+
import java.security.cert.CertificateFactory
30+
import java.security.spec.PKCS8EncodedKeySpec
31+
import java.security.{KeyException, KeyFactory, KeyStore, PrivateKey}
32+
import javax.net.ssl.{KeyManagerFactory, SSLContext}
33+
34+
/**
35+
*
36+
* All the certificates are picked up from the filesystem. You have 3 ways to specify the Let's Encrypt directory to use:
37+
* - Via an environment variable 'LETSENCRYPT_CERT_DIR'
38+
* - Via a System property 'letsencrypt.cert.dir',
39+
* - Programmatically with a [[java.nio.file.Path]] - we leave this in case it is needed, for example, in corporate environments,
40+
* where you may be fetching stuff based on a configuration option you fetch from elsewhere.
41+
*
42+
* All the file paths are normalised before reading, and then are read with BouncyCastle's PemReader.
43+
*
44+
* Once the certificates are successfully fetched, you can:
45+
* - Get a standard [[javax.net.ssl.SSLContext]], so that you can use it in combination with libraries that are not FS2-TLS based.
46+
* For example, http4s-blaze will use SSLContext, but http4s-ember will use TLSContext.
47+
*
48+
* All the certificates are supplied as [[cats.effect.Resource]] types, so that all the clean-ups are taken care of for you.
49+
* This also includes clearing out Arrays that should not retain passwords, private keys, and so forth, once they are no longer needed.
50+
*
51+
* This being a convenience library, these will be 2 most common use cases:
52+
*
53+
* @example {{{
54+
* LetsEncryptScala
55+
* .fromEnvironment[IO]
56+
* .flatMap(sslContext => /* Use with fs2/http4s/other servers */ )
57+
* }}}
58+
*
59+
*/
60+
object LetsEncryptScala {
61+
62+
/**
63+
* Load the certificate configuration from a directory specified.
64+
*/
65+
def fromLetsEncryptDirectory[F[_]: Sync](
66+
certificateDirectory: Path): Resource[F, LetsEncryptScala] =
67+
(loadCertificateChain(certificateDirectory),
68+
loadPrivateKey(certificateDirectory))
69+
.mapN(new LetsEncryptScala(_, _))
70+
71+
/** Load the certificate configuration from a directory specified as
72+
* an environment variable or as a system property.
73+
*
74+
* The system property is prioritised over the environment variable
75+
* because it is more specific.
76+
*/
77+
def fromEnvironment[F[_]: Sync]: Resource[F, LetsEncryptScala] =
78+
Resource
79+
.eval {
80+
Sync[F]
81+
.delay {
82+
Paths
83+
.get(
84+
sys.props
85+
.get(DefaultSysPropertyName)
86+
.orElse(sys.env
87+
.get(DefaultEnvVarName))
88+
.getOrElse(
89+
sys.error(
90+
s"Expected environment variable '$DefaultEnvVarName' or system property '$DefaultSysPropertyName'"
91+
)
92+
)
93+
)
94+
.toAbsolutePath
95+
}
96+
}
97+
.flatMap(path => fromLetsEncryptDirectory(path))
98+
99+
/**
100+
* Default environment variable name to use by [[fromEnvironment]].
101+
*/
102+
val DefaultEnvVarName = "LETSENCRYPT_CERT_DIR"
103+
104+
/**
105+
* Default Java system property name to use by [[fromEnvironment]].
106+
*/
107+
val DefaultSysPropertyName = "letsencrypt.cert.dir"
108+
109+
private[letsencrypt] val PrivateKeyAlias = "PrivateKeyAlias"
110+
private[letsencrypt] val CertificateAliasPrefix = "CertificateAlias"
111+
112+
/**
113+
* The filename of the 'full chain' file in the LetsEncrypt certificate directory.
114+
* This normally contains the root certificate all the way through intermediate certificates
115+
* and to our own certificate. Let's Encrypt only has 2, but no guarantee that it will
116+
* continue the case.
117+
*/
118+
val LetsEncryptFullChainPemFilename = "fullchain.pem"
119+
120+
/**
121+
* The filename of the 'private key' file in the LetsEncrypt certificate directory.
122+
*
123+
* Try to restrict access to this as much as you possibly can because this
124+
* you don't want to get in the wrong hands.
125+
*
126+
* When setting up on Linux environments, there is a good chance that
127+
* you would need to share this file with your Java application,
128+
* and the easiest way to give that access is to use a command `setfacl -m u:javaappuser:r /path/to/privkey.pem`,
129+
* so that you can give access to ''javaappuser'' for this file specifically.
130+
*/
131+
val LetsEncryptPrivateKeyPemFilename = "privkey.pem"
132+
133+
private def loadPrivateKey[F[_]: Sync](
134+
certificateDirectory: Path): Resource[F, Array[Byte]] = {
135+
val privateKeyPath =
136+
certificateDirectory
137+
.resolve(LetsEncryptPrivateKeyPemFilename)
138+
.toAbsolutePath
139+
loadChain(privateKeyPath)
140+
.map(
141+
_.headOption.getOrElse(
142+
throw new KeyException(
143+
s"Could not extract a private key from ${privateKeyPath}"
144+
)
145+
))
146+
}
147+
148+
private def loadCertificateChain[F[_]: Sync](
149+
certificateDirectory: Path): Resource[F, List[Array[Byte]]] = {
150+
val chainPath =
151+
certificateDirectory
152+
.resolve(LetsEncryptFullChainPemFilename)
153+
.toAbsolutePath
154+
loadChain(chainPath).map(
155+
_.ensuring(_.nonEmpty,
156+
s"Could not extract a single certificate from $chainPath"))
157+
}
158+
}
159+
160+
final class LetsEncryptScala(certificateChain: List[Array[Byte]],
161+
privateKeyBytes: Array[Byte]) {
162+
163+
private[letsencrypt] def addToKeyStore[F[_]: Sync](
164+
keyStore: KeyStore,
165+
withPassword: Array[Char]): F[Unit] =
166+
Sync[F].delay {
167+
val certificates = certificateChain.map { bytes =>
168+
CertificateFactory
169+
.getInstance("X.509")
170+
.generateCertificate(new ByteArrayInputStream(bytes))
171+
}
172+
certificates.zipWithIndex.foreach {
173+
case (certificate, index) =>
174+
keyStore.setCertificateEntry(s"$CertificateAliasPrefix$index",
175+
certificate)
176+
}
177+
keyStore.setKeyEntry(
178+
PrivateKeyAlias,
179+
makePrivateKey(),
180+
withPassword,
181+
certificates.toArray
182+
)
183+
}
184+
185+
private[letsencrypt] def makePrivateKey(): PrivateKey =
186+
KeyFactory
187+
.getInstance("RSA")
188+
.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes))
189+
190+
def sslContextResource[F[_]: Sync]: Resource[F, SSLContext] =
191+
for {
192+
internalPassword <- randomPassword[F]
193+
keyStore <- Resource.eval {
194+
Sync[F].delay {
195+
val keyStore = KeyStore.getInstance("PKCS12")
196+
keyStore.load(null)
197+
keyStore
198+
}
199+
}
200+
_ <- Resource.eval(addToKeyStore(keyStore, internalPassword))
201+
sslContext <- Resource.eval {
202+
Sync[F].delay {
203+
val keyManagerFactory =
204+
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
205+
keyManagerFactory.init(keyStore, internalPassword)
206+
val sslContext = SSLContext.getInstance("TLS")
207+
sslContext.init(keyManagerFactory.getKeyManagers, null, null)
208+
sslContext
209+
}
210+
}
211+
} yield sslContext
212+
213+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.scalawilliam.letsencrypt
2+
3+
import cats.effect.{Resource, Sync}
4+
import org.bouncycastle.util.io.pem.PemReader
5+
6+
import java.io.{FileReader, Reader}
7+
import java.nio.file.Path
8+
9+
private[letsencrypt] object LetsEncryptScalaUtils {
10+
11+
def loadChain[F[_]: Sync](filePath: Path): Resource[F, List[Array[Byte]]] =
12+
clearableListByteArray {
13+
Sync[F].delay {
14+
extractDER(new FileReader(filePath.toFile))
15+
}
16+
}
17+
18+
def extractDER(reader: => Reader): List[Array[Byte]] = {
19+
val readerInstance = reader
20+
try {
21+
val pemReader = new PemReader(readerInstance)
22+
try Iterator
23+
.continually(Option(pemReader.readPemObject()))
24+
.takeWhile(_.isDefined)
25+
.flatten
26+
.flatMap(o => Option(o.getContent))
27+
.toList
28+
finally pemReader.close()
29+
} finally readerInstance.close()
30+
}
31+
32+
def clearableCharArray[F[_]: Sync](
33+
f: F[Array[Char]]): Resource[F, Array[Char]] =
34+
Resource.make(f)(array =>
35+
Sync[F].delay {
36+
java.util.Arrays.fill(array, '0')
37+
})
38+
39+
def clearableByteArray[F[_]: Sync](
40+
f: F[Array[Byte]]): Resource[F, Array[Byte]] =
41+
Resource.make(f)(array =>
42+
Sync[F].delay {
43+
java.util.Arrays.fill(array, 0.toByte)
44+
})
45+
46+
def clearableListByteArray[F[_]: Sync](
47+
f: F[List[Array[Byte]]]): Resource[F, List[Array[Byte]]] =
48+
Resource.make(f)(list =>
49+
Sync[F].delay {
50+
list.foreach(array => java.util.Arrays.fill(array, 0.toByte))
51+
})
52+
53+
val PrintableRange: Range.Inclusive = 0x20 to 0x7E
54+
55+
def randomPassword[F[_]: Sync]: Resource[F, Array[Char]] =
56+
Resource.eval(Sync[F].delay(16 + scala.util.Random.nextInt(20))).flatMap {
57+
length =>
58+
clearableCharArray[F] {
59+
Sync[F].delay {
60+
Array.fill(length) {
61+
PrintableRange(
62+
scala.util.Random.nextInt(PrintableRange.length - 1)).toChar
63+
}
64+
}
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)