Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions otoroshi/app/ssl/ssl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,22 @@ object Cert {
c.copy(name = c.domain, description = s"Certificate for ${c.subject}")
}

def apply(chain: Seq[X509Certificate], keyPair: KeyPair, client: Boolean): Cert = {
val c = Cert(
id = IdGenerator.token(32),
name = "none",
description = "none",
chain = chain.map(_.asPem).mkString("\n"),
privateKey = keyPair.getPrivate.asPem,
caRef = None,
autoRenew = false,
client = client,
exposed = false,
revoked = false
).enrich()
c.copy(name = c.domain, description = s"Certificate for ${c.subject}")
}

def apply(cert: X509Certificate, keyPair: KeyPair, ca: X509Certificate, client: Boolean): Cert = {
val c = Cert(
id = IdGenerator.token(32),
Expand Down
90 changes: 82 additions & 8 deletions otoroshi/app/utils/letsencrypt.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
import java.security.{KeyFactory, KeyPair}
import java.util.Base64
import java.util.concurrent.Executors
import javax.naming.ldap.LdapName
import javax.security.auth.x500.X500Principal
import scala.collection.JavaConverters._
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
Expand All @@ -33,7 +35,8 @@ case class LetsEncryptSettings(
emails: Seq[String] = Seq.empty,
contacts: Seq[String] = Seq.empty,
publicKey: String = "",
privateKey: String = ""
privateKey: String = "",
preferredChain: Option[String] = None
) {

def json: JsValue = LetsEncryptSettings.format.writes(this)
Expand Down Expand Up @@ -85,7 +88,8 @@ object LetsEncryptSettings {
.filter(_.nonEmpty)
.getOrElse(Seq.empty),
publicKey = (json \ "publicKey").asOpt[String].getOrElse(""),
privateKey = (json \ "privateKey").asOpt[String].getOrElse("")
privateKey = (json \ "privateKey").asOpt[String].getOrElse(""),
preferredChain = (json \ "preferredChain").asOpt[String].map(_.trim).filter(_.nonEmpty)
)
} match {
case Success(s) => JsSuccess(s)
Expand All @@ -99,7 +103,8 @@ object LetsEncryptSettings {
"emails" -> JsArray(o.emails.map(JsString.apply)),
"contacts" -> JsArray(o.contacts.map(JsString.apply)),
"publicKey" -> o.publicKey,
"privateKey" -> o.privateKey
"privateKey" -> o.privateKey,
"preferredChain" -> o.preferredChain
)
}
}
Expand Down Expand Up @@ -166,11 +171,13 @@ object LetsEncryptHelper {
FastFuture.successful(Left("No certificate found !"))
case Some(c) => {
// env.datastores.rawDataStore.del(Seq(s"${env.storageRoot}:letsencrypt:challenges:$domain:$token"))
val ca: X509Certificate = c.getCertificateChain.get(1)
val certificate: X509Certificate = c.getCertificate
val cert =
Cert.apply(certificate, keyPair, ca, false).copy(letsEncrypt = true, autoRenew = true).enrich()
cert.save().map(_ => Right(cert))
selectCertificateChain(c, domain, letsEncryptSettings.preferredChain).flatMap { chain =>
val cert = Cert
.apply(chain, keyPair, false)
.copy(letsEncrypt = true, autoRenew = true)
.enrich()
cert.save().map(_ => Right(cert))
}
}
}
}
Expand All @@ -182,6 +189,73 @@ object LetsEncryptHelper {
}
}

// Extracts the Common Name (CN) of an X.500 principal, tolerant to the other RDN
// components (O, C, ...) that real-world CA certificates always carry. Parsing the
// DN is required because raw string equality (as acme4j's Certificate.isIssuedBy does)
// never matches a real Let's Encrypt anchor, whose issuer DN is e.g.
// "CN=ISRG Root X1,O=Internet Security Research Group,C=US".
private def commonNameOf(principal: X500Principal): Option[String] = {
Try {
new LdapName(principal.getName).getRdns.asScala
.find(_.getType.equalsIgnoreCase("CN"))
.map(_.getValue.toString.trim)
}.toOption.flatten.filter(_.nonEmpty)
}

// The trust anchor a chain terminates at is identified by the issuer of its topmost
// certificate: the highest cert included in the bundle is signed by the root, which
// is itself usually not bundled. This is the same signal certbot uses for its
// `--preferred-chain` option.
private def chainAnchorCn(chain: Seq[X509Certificate]): Option[String] = {
chain.lastOption.flatMap(top => commonNameOf(top.getIssuerX500Principal))
}

// Selects, among the default chain and the ACME `alternate` chains advertised by the
// server, the one whose trust anchor CN matches `preferredChain`. When no preference
// is set (or none of the available chains match), the default chain is returned as-is,
// preserving the historical behavior. Runs on the blocking pool since fetching the
// alternate chains performs network calls.
private def selectCertificateChain(
certificate: org.shredzone.acme4j.Certificate,
domain: String,
preferredChain: Option[String]
): Future[Seq[X509Certificate]] = {
Future {
val defaultChain = certificate.getCertificateChain.asScala.toSeq
preferredChain.map(_.trim).filter(_.nonEmpty) match {
case None => defaultChain
case Some(anchor) =>
// resolving the alternate chains performs extra network calls: any failure here
// must never break the issuance, we just fall back to the default chain.
Try {
val candidateChains =
(certificate +: certificate.getAlternateCertificates.asScala.toSeq).map(_.getCertificateChain.asScala.toSeq)
candidateChains.find(chain => chainAnchorCn(chain).exists(_.equalsIgnoreCase(anchor))) match {
case Some(chain) =>
if (logger.isDebugEnabled)
logger.debug(s"using let's encrypt chain anchored at '$anchor' for $domain")
chain
case None =>
logger.warn(
s"no let's encrypt chain matching preferred trust anchor '$anchor' for $domain " +
s"(available anchors: ${candidateChains.flatMap(chainAnchorCn).distinct.mkString(", ")}). " +
s"falling back to the default chain"
)
defaultChain
}
} match {
case Success(chain) => chain
case Failure(e) =>
logger.error(
s"error while selecting preferred let's encrypt chain '$anchor' for $domain, falling back to the default chain",
e
)
defaultChain
}
}
}(blockingEc)
}

def getChallengeForToken(domain: String, token: String)(implicit
ec: ExecutionContext,
env: Env,
Expand Down
9 changes: 9 additions & 0 deletions otoroshi/javascript/src/pages/DangerZonePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,14 @@ export class DangerZonePage extends Component {
style: { fontFamily: 'monospace' },
},
},
'letsEncryptSettings.preferredChain': {
type: 'string',
props: {
label: 'Preferred chain',
placeholder: 'e.g. ISRG Root X1',
help: "CN of the preferred root/trust anchor. When set, Otoroshi presents the ACME alternate chain that terminates at this root (matched on the issuer CN of the topmost certificate, like certbot's --preferred-chain). Leave empty to keep the ACME server's default chain.",
},
},
'mailGunSettings.eu': {
type: 'bool',
props: {
Expand Down Expand Up @@ -1382,6 +1390,7 @@ export class DangerZonePage extends Component {
'letsEncryptSettings.contacts',
'letsEncryptSettings.publicKey',
'letsEncryptSettings.privateKey',
'letsEncryptSettings.preferredChain',
'>>>CleverCloud settings',
'cleverSettings.consumerKey',
'cleverSettings.consumerSecret',
Expand Down
Loading