Compare commits

...

1 Commits

Author SHA1 Message Date
Gabriel Cruz
b755f9e13e feat(autotls): add AutoTLS manager 2025-06-13 11:58:08 -03:00
4 changed files with 310 additions and 32 deletions

View File

@@ -19,8 +19,10 @@ const
DefaultRandStringSize = 256
ACMEHttpHeaders = [("Content-Type", "application/jose+json")]
type Nonce* = string
type Domain* = string
type Kid* = string
type Nonce* = string
type SignedACMERequest* = string
type ACMEDirectory* = object
newNonce*: string
@@ -95,12 +97,12 @@ type ACMEChallengeRequest = object
type ACMEChallengeResponseBody = object
status: ACMEChallengeStatus
authorizations: seq[string]
finalize: string
finalizeURL: string
type ACMEChallengeResponse* = object
status*: ACMEChallengeStatus
authorizations*: seq[string]
finalize*: string
finalizeURL*: string
orderURL*: string
type ACMEChallengeResponseWrapper* = object
@@ -161,13 +163,13 @@ template handleError*(msg: string, body: untyped): untyped =
raise newException(ACMEError, msg & ": Unexpected error", exc)
method post*(
self: ACMEApi, url: string, payload: string
self: ACMEApi, url: Uri, payload: SignedACMERequest
): Future[HTTPResponse] {.
async: (raises: [ACMEError, HttpError, CancelledError]), base
.}
method get*(
self: ACMEApi, url: string
self: ACMEApi, url: Uri
): Future[HTTPResponse] {.
async: (raises: [ACMEError, HttpError, CancelledError]), base
.}
@@ -225,39 +227,39 @@ proc acmeHeader(
)
method post*(
self: ACMEApi, url: string, payload: string
self: ACMEApi, uri: Uri, payload: SignedACMERequest
): Future[HTTPResponse] {.
async: (raises: [ACMEError, HttpError, CancelledError]), base
.} =
let rawResponse = await HttpClientRequestRef
.post(self.session, url, body = payload, headers = ACMEHttpHeaders)
.post(self.session, $uri, body = payload, headers = ACMEHttpHeaders)
.get()
.send()
let body = await rawResponse.getResponseBody()
HTTPResponse(body: body, headers: rawResponse.headers)
method get*(
self: ACMEApi, url: string
self: ACMEApi, uri: Uri
): Future[HTTPResponse] {.
async: (raises: [ACMEError, HttpError, CancelledError]), base
.} =
let rawResponse = await HttpClientRequestRef.get(self.session, url).get().send()
let rawResponse = await HttpClientRequestRef.get(self.session, $uri).get().send()
let body = await rawResponse.getResponseBody()
HTTPResponse(body: body, headers: rawResponse.headers)
proc createSignedAcmeRequest(
proc createSignedACMERequest(
self: ACMEApi,
url: string,
uri: Uri,
payload: auto,
key: KeyPair,
needsJwk: bool = false,
kid: Opt[Kid] = Opt.none(Kid),
): Future[string] {.async: (raises: [ACMEError, CancelledError]).} =
): Future[SignedACMERequest] {.async: (raises: [ACMEError, CancelledError]).} =
if key.pubkey.scheme != PKScheme.RSA or key.seckey.scheme != PKScheme.RSA:
raise newException(ACMEError, "Unsupported signing key type")
let acmeHeader = await self.acmeHeader(url, key, needsJwk, kid)
handleError("createSignedAcmeRequest"):
handleError("createSignedACMERequest"):
var token = toJWT(%*{"header": acmeHeader, "claims": payload})
let derPrivKey = key.seckey.rsakey.getBytes.get
let pemPrivKey: string = pemEncode(derPrivKey, "PRIVATE KEY")
@@ -269,7 +271,7 @@ proc requestRegister*(
): Future[ACMERegisterResponse] {.async: (raises: [ACMEError, CancelledError]).} =
let registerRequest = ACMERegisterRequest(termsOfServiceAgreed: true)
handleError("acmeRegister"):
let payload = await self.createSignedAcmeRequest(
let payload = await self.createSignedACMERequest(
self.directory.newAccount, registerRequest, key, needsJwk = true
)
let acmeResponse = await self.post(self.directory.newAccount, payload)
@@ -280,14 +282,14 @@ proc requestRegister*(
)
proc requestNewOrder*(
self: ACMEApi, domains: seq[string], key: KeyPair, kid: Kid
self: ACMEApi, domains: seq[Domain], key: KeyPair, kid: Kid
): Future[ACMEChallengeResponse] {.async: (raises: [ACMEError, CancelledError]).} =
# request challenge from ACME server
let orderRequest = ACMEChallengeRequest(
identifiers: domains.mapIt(ACMEChallengeIdentifier(`type`: "dns", value: it))
)
handleError("requestNewOrder"):
let payload = await self.createSignedAcmeRequest(
let payload = await self.createSignedACMERequest(
self.directory.newOrder, orderRequest, key, kid = Opt.some(kid)
)
let acmeResponse = await self.post(self.directory.newOrder, payload)
@@ -298,7 +300,7 @@ proc requestNewOrder*(
ACMEChallengeResponse(
status: challengeResponseBody.status,
authorizations: challengeResponseBody.authorizations,
finalize: challengeResponseBody.finalize,
finalizeURL: challengeResponseBody.finalize,
orderURL: acmeResponse.headers.keyOrError("location"),
)
@@ -311,7 +313,7 @@ proc requestAuthorizations*(
acmeResponse.body.to(ACMEAuthorizationsResponse)
proc requestChallenge*(
self: ACMEApi, domains: seq[string], key: KeyPair, kid: Kid
self: ACMEApi, domains: seq[Domain], key: KeyPair, kid: Kid
): Future[ACMEChallengeResponseWrapper] {.async: (raises: [ACMEError, CancelledError]).} =
let challengeResponse = await self.requestNewOrder(domains, key, kid)
@@ -325,7 +327,7 @@ proc requestChallenge*(
)
proc requestCheck*(
self: ACMEApi, checkURL: string, checkKind: ACMECheckKind, key: KeyPair, kid: Kid
self: ACMEApi, checkURL: Uri, checkKind: ACMECheckKind, key: KeyPair, kid: Kid
): Future[ACMECheckResponse] {.async: (raises: [ACMEError, CancelledError]).} =
handleError("requestCheck"):
let acmeResponse = await self.get(checkURL)
@@ -364,7 +366,7 @@ proc requestCompleted*(
): Future[ACMECompletedResponse] {.async: (raises: [ACMEError, CancelledError]).} =
handleError("requestCompleted (send notify)"):
let payload =
await self.createSignedAcmeRequest(chalURL, %*{}, key, kid = Opt.some(kid))
await self.createSignedACMERequest(chalURL, %*{}, key, kid = Opt.some(kid))
let acmeResponse = await self.post(chalURL, payload)
acmeResponse.body.to(ACMECompletedResponse)
@@ -402,13 +404,13 @@ proc completeChallenge*(
return await self.checkChallengeCompleted(chalURL, key, kid, retries = retries)
proc requestFinalize*(
self: ACMEApi, domain: string, finalizeURL: string, key: KeyPair, kid: Kid
self: ACMEApi, domain: Domain, finalizeURL: uri, key: KeyPair, kid: Kid
): Future[ACMEFinalizeResponse] {.async: (raises: [ACMEError, CancelledError]).} =
let derCSR = createCSR(domain)
let b64CSR = base64.encode(derCSR.toSeq, safe = true)
handleError("requestFinalize"):
let payload = await self.createSignedAcmeRequest(
let payload = await self.createSignedACMERequest(
finalizeURL, %*{"csr": b64CSR}, key, kid = Opt.some(kid)
)
let acmeResponse = await self.post(finalizeURL, payload)
@@ -417,7 +419,7 @@ proc requestFinalize*(
proc checkCertFinalized*(
self: ACMEApi,
orderURL: string,
orderURL: Uri,
key: KeyPair,
kid: Kid,
retries: int = DefaultChalCompletedRetries,
@@ -441,9 +443,9 @@ proc checkCertFinalized*(
proc certificateFinalized*(
self: ACMEApi,
domain: string,
finalizeURL: string,
orderURL: string,
domain: Domain,
finalizeURL: Uri,
orderURL: Uri,
key: KeyPair,
kid: Kid,
retries: int = DefaultFinalizeRetries,
@@ -453,16 +455,16 @@ proc certificateFinalized*(
return await self.checkCertFinalized(orderURL, key, kid, retries = retries)
proc requestGetOrder*(
self: ACMEApi, orderURL: string
self: ACMEApi, orderURL: Uri
): Future[ACMEOrderResponse] {.async: (raises: [ACMEError, CancelledError]).} =
handleError("requestGetOrder"):
let acmeResponse = await self.get(orderURL)
let acmeResponse = await self.get($orderURL)
acmeResponse.body.to(ACMEOrderResponse)
proc downloadCertificate*(
self: ACMEApi, orderURL: string
self: ACMEApi, orderURL: Uri
): Future[ACMECertificateResponse] {.async: (raises: [ACMEError, CancelledError]).} =
let orderResponse = await self.requestGetOrder(orderURL)
let orderResponse = await self.requestGetOrder($orderURL)
handleError("downloadCertificate"):
let rawResponse = await HttpClientRequestRef

View File

@@ -0,0 +1,38 @@
# Nim-Libp2p
# Copyright (c) 2025 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
# at your option.
# This file may not be copied, modified, or distributed except according to
# those terms.
{.push raises: [].}
import chronos, results
import ./api
type KeyAuthorization* = string
type ACMEClient* = object
api: ACMEApi
key*: KeyPair
kid*: Kid
proc new*(
T: typedesc[ACMEClient],
key: Opt[KeyPair] = Opt.none(KeyPair),
acmeServerURL: string = LetsEncryptURL,
): Future[T] {.async: (raises: [ACMEError, CancelledError]).} =
let api = await ACMEApi.new()
let key = key.valueOr:
KeyPair.random(PKScheme.RSA, self.rng[]).get()
let kid = await api.requestRegister(key)
T(api: api, key: key, kid: kid)
proc genKeyAuthorization*(self: ACMEClient, domains: seq[Domain]): KeyAuthorization =
let dns01 = self.api.requestChallenge(domains, self.key, self.kid)
base64UrlEncode(
@(sha256.digest((dns01.token & "." & thumbprint(self.key)).toByteSeq).data)
)

View File

@@ -27,11 +27,11 @@ method requestNonce*(
return self.acmeServerURL & "/acme/1234"
method post*(
self: MockACMEApi, url: string, payload: string
self: MockACMEApi, uri: Uri, payload: SignedACMERequest
): Future[HTTPResponse] {.async: (raises: [ACMEError, HttpError, CancelledError]).} =
HTTPResponse(body: self.mockedBody, headers: self.mockedHeaders)
method get*(
self: MockACMEApi, url: string
self: MockACMEApi, uri: Uri
): Future[HTTPResponse] {.async: (raises: [ACMEError, HttpError, CancelledError]).} =
HTTPResponse(body: self.mockedBody, headers: self.mockedHeaders)

238
libp2p/autotls/client.nim Normal file
View File

@@ -0,0 +1,238 @@
# Nim-Libp2p
# Copyright (c) 2025 Status Research & Development GmbH
# Licensed under either of
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
# at your option.
# This file may not be copied, modified, or distributed except according to
# those terms.
{.push raises: [].}
{.push public.}
import
net,
results,
chronos,
chronicles,
bearssl/rand,
bio,
json,
sequtils,
chronos/apps/http/httpclient
import ./acme/client
import ../peeridauth/client
import ../nameresolving/dnsresolver
import ../wire
import ../crypto/crypto
import ../peerinfo
import ../utils/heartbeat
logScope:
topics = "libp2p autotls"
const
DefaultDnsServers =
@[
initTAddress("1.1.1.1:53"),
initTAddress("1.0.0.1:53"),
initTAddress("[2606:4700:4700::1111]:53"),
]
DefaultRenewCheckTime = 1.hours
DefaultDnsRetries = 10
DefaultDnsRetryTime = 1.seconds
type AutoTLSCertificate* = object
cert*: TLSCertificate
expiry*: Moment
type AutoTLSClient = ref object
bearer*: Opt[BearerToken]
peerInfo: Opt[PeerInfo]
peerIDAuthClient: PeerIDAuthClient
type AutoTLSManager* = object
autoTLSClient: AutoTLSClient
acmeClient: ACMEClient
cert*: Opt[AutoTLSCertificate]
certReady*: AsyncEvent
dnsResolver: DnsResolver
fut: Future[void]
ipAddress: Opt[IpAddress]
renewCheckTime: Duration
proc new*(
T: typedesc[AutoTLSManager],
acmeClient: ref ACMEClient = nil,
dnsResolver: DnsResolver = DnsResolver.new(DefaultDnsServers),
ipAddress: Opt[IpAddress] = Opt.none(IpAddress),
): AutoTLSManager =
T(
fut: nil,
cert: Opt.none(AutoTLSCertificate),
certReady: newAsyncEvent(),
acmeClient: acmeClient,
dnsResolver: dnsResolver,
peerInfo: Opt.none(PeerInfo),
bearerToken: Opt.none(BearerToken),
renewCheckTime: DefaultRenewCheckTime,
ipAddress: ipAddress,
)
proc checkDNSRecords(
self: AutoTLSManager,
ip4Domain: string,
acmeChalDomain: string,
keyAuthorization: KeyAuthorization
retries: int = DefaultDnsRetries,
): Future[bool] {.async: (raises: [AutoTLSError, CancelledError]).} =
var txt: seq[string]
var ip4: seq[TransportAddress]
for _ in 0 .. retries:
txt = await self.dnsResolver.resolveTxt(acmeChalDomain)
try:
ip4 = await self.dnsResolver.resolveIp(ip4Domain, 0.Port)
except CatchableError as exc:
error "Failed to resolve IP", description = exc.msg # retry
if txt.len > 0 and txt[0] == keyAuthorization and ip4.len > 0:
return true
await sleepAsync(DefaultDnsRetryTime)
return false
method issueCertificate(
self: AutoTLSManager
): Future[void] {.base, async: (raises: [AutoTLSError, CancelledError]).} =
trace "Issuing new certificate"
let peerInfo = self.peerInfo.valueOr:
raise newException(AutoTLSError, "Cannot issue new certificate: peerInfo not set")
# generate autotls domain string: "*.{peerID}.libp2p.direct"
let base36PeerId = encodePeerId(peerInfo.peerId)
let baseDomain = base36PeerId & "." & AutoTLSDNSServer
let domain = "*." & baseDomain
trace "Requesting ACME challenge"
let keyAuthorization = self.acmeClient.getKeyAuthorization(@[domain])
trace "Sending challenge to AutoTLS broker"
let strMultiaddresses: seq[string] = peerInfo.addrs.mapIt($it)
let payload = %*{"value": keyAuthorization, "addresses": strMultiaddresses}
let registrationURL = "https://" & AutoTLSBroker & "/v1/_acme-challenge"
var response: HttpClientResponseRef
var bearerToken: BearerToken
if self.bearerToken.isSome:
(bearerToken, response) = await peerIdAuthSend(
registrationURL,
self.httpSession,
peerInfo,
payload,
bearerToken = self.bearerToken,
)
else:
# authenticate, send challenge and save bearerToken for future requests
(bearerToken, response) =
await peerIdAuthSend(registrationURL, self.httpSession, peerInfo, payload)
self.bearerToken = Opt.some(bearerToken)
if response.status != HttpOk:
raise newException(
AutoTLSError, "Failed to authenticate with AutoTLS Broker at " & AutoTLSBroker
)
# no need to do anything from this point forward if there are not public ip addresses on host
let hostPrimaryIP: IpAddress =
try:
let ip = self.ipAddress.valueOr:
checkedGetPrimaryIPAddr()
if not isPublicIPv4(ip):
raise newException(AutoTLSError, "Host does not have a public IPv4 address")
ip
except GetPrimaryIPError as exc:
raise newException(AutoTLSError, "Failed to get primary IP address for host", exc)
except CatchableError as exc:
raise newException(
AutoTLSError, "Unexpected error while getting primary IP address for host", exc
)
debug "Waiting for DNS record to be set"
# if my ip address is 100.10.10.3 then the ip4Domain will be:
# 100-10-10-3.{peerIdBase36}.libp2p.direct
# and acme challenge TXT domain will be:
# _acme-challenge.{peerIdBase36}.libp2p.direct
let dashedIpAddr = ($hostPrimaryIP).replace(".", "-")
let acmeChalDomain = "_acme-challenge." & baseDomain
let ip4Domain = dashedIpAddr & "." & baseDomain
if not await self.checkDNSRecords(ip4Domain, acmeChalDomain, keyAuthorization):
raise newException(AutoTLSError, "DNS records not set")
debug "Notifying challenge completion to ACME server"
let chalURL = dns01Challenge.getJSONField("url").getStr
await self.acmeClient.notifyChallengeCompleted(chalURL)
debug "Finalize cert request with CSR"
if not await self.acmeClient.finalizeCertificate(domain, finalizeURL, orderURL):
raise newException(AutoTLSError, "ACME certificate finalization request failed")
debug "Downloading certificate"
let (rawCert, expiry) = await self.acmeClient.downloadCertificate(orderURL)
trace "Installing certificate"
try:
self.cert = Opt.some(TLSCertificate.init(rawCert))
self.certExpiry = Opt.some(asMoment(expiry))
except TLSStreamProtocolError:
raise newException(AutoTLSError, "Could not parse downloaded certificates")
self.certReady.fire()
proc manageCertificate(
self: AutoTLSManager
): Future[void] {.async: (raises: [AutoTLSError, CancelledError]).} =
trace "Starting AutoTLS manager"
debug "Registering ACME Client"
if self.acmeClient.isNil:
self.acmeClient = await ACMEClient.new()
heartbeat "Certificate Management", self.renewCheckTime:
if self.cert.isNone or self.certExpiry.isNone:
try:
await self.issueCertificate()
except CatchableError as exc:
error "Failed to issue certificate", err = exc.msg
break
# AutoTLSManager will renew the cert 1h before it expires
let expiry = self.certExpiry.get
let waitTime: Duration = expiry - Moment.now - self.renewCheckTime
if waitTime <= self.renewCheckTime:
try:
await self.issueCertificate()
except CatchableError as exc:
error "Failed to renew certificate", err = exc.msg
break
method start*(
self: AutoTLSManager, peerInfo: PeerInfo
): Future[void] {.base, async: (raises: [CancelledError]).} =
if not self.fut.isNil:
warn "Starting AutoTLS twice"
return
self.peerInfo = Opt.some(peerInfo)
self.fut = self.manageCertificate()
method stop*(self: AutoTLSManager): Future[void] {.base, async: (raises: []).} =
trace "AutoTLS stop"
if self.fut.isNil:
warn "Stopping AutoTLS without starting it"
return
await self.fut.cancelAndWait()
self.fut = nil
if not self.acmeClient.isNil:
await self.acmeClient.session.closeWait()
if not self.httpSession.isNil:
await self.httpSession.closeWait()