Compare commits

...

27 Commits

Author SHA1 Message Date
Gabriel Cruz
2a2a39838a chore(autotls): comment out withAutoTLSMgr 2025-05-30 09:53:21 -03:00
Gabriel Cruz
26d6605427 chore(autotls): move directory json into ACMEDirectory 2025-05-30 09:15:40 -03:00
Gabriel Cruz
8d8a6b25cb fix(autotls): address comments 2025-05-29 16:50:11 -03:00
Gabriel Cruz
b38fcf8703 fix(autotls): proper exception handling 2025-05-29 16:24:30 -03:00
Gabriel Cruz
4cfad9cf95 fix(autotls): make it work with nim 1.6 2025-05-29 16:24:30 -03:00
Gabriel Cruz
f0adbdf837 chore(autotls): add ip address option 2025-05-29 16:24:30 -03:00
Gabriel Cruz
11fec4dc15 fix(autotls): retry times 2025-05-29 16:24:29 -03:00
Gabriel Cruz
425ccf8266 tidy up autotls 2025-05-29 16:24:29 -03:00
Gabriel Cruz
ccfac50393 fix(autotls): tidy up peeridauth 2025-05-29 16:24:29 -03:00
Gabriel Cruz
65a8fa4760 chore(autotls): use letsencrypt staging for autotls tests 2025-05-29 16:24:29 -03:00
Gabriel Cruz
7d0a1af0af fix(autotls): tidy up tests 2025-05-29 16:24:29 -03:00
Gabriel Cruz
506014a623 fix(autotls): replace Exception with CatchableError 2025-05-29 16:24:28 -03:00
Gabriel Cruz
441868a86b fix(autotls): start addressing comments on pr 2025-05-29 16:24:28 -03:00
Gabriel Cruz
b219239693 fix(autotls): re-include acme tests 2025-05-29 16:24:28 -03:00
Gabriel Cruz
e4f0363c75 feat(autotls): use AsyncEvent 2025-05-29 16:24:28 -03:00
Gabriel Cruz
f87706ee41 feat(autotls): add certificate renewal 2025-05-29 16:24:28 -03:00
Gabriel Cruz
51ab4bfff1 feat(autotls): add certificate parsing 2025-05-29 16:24:28 -03:00
Gabriel Cruz
4fd6fc44e0 fix(autotls): test with switch on 2025-05-29 16:24:27 -03:00
Gabriel Cruz
5541ff4f64 optimize dns query check 2025-05-29 16:24:27 -03:00
Gabriel Cruz
7222b3300a fix(autotls): fix tests 2025-05-29 16:24:27 -03:00
Gabriel Cruz
7f3e29e110 fix(autotls): add dnsresolver to autotlsmgr 2025-05-29 16:24:27 -03:00
Gabriel Cruz
ad6b3e901d chore(autotls): use vacp2p's nim-jwt 2025-05-29 16:24:27 -03:00
Gabriel Cruz
f994c499e8 feat(autotls): notify challenge completion to ACME server 2025-05-29 16:24:26 -03:00
Gabriel Cruz
1534dcf807 feat(autotls): check if DNS records are set 2025-05-29 16:24:26 -03:00
Gabriel Cruz
b38f1cde15 feat(autotls): check challenge completion 2025-05-29 16:24:26 -03:00
Gabriel Cruz
f1141b1287 feat(autotls): check server signature (peer ID auth) 2025-05-29 16:24:26 -03:00
Gabriel Cruz
0edd96c642 feat(autotls): peerID authentication delivery to autoTLS broker 2025-05-29 16:24:26 -03:00
10 changed files with 1097 additions and 2 deletions

View File

@@ -17,3 +17,6 @@ testutils;https://github.com/status-im/nim-testutils@#9e842bd58420d23044bc55e160
unittest2;https://github.com/status-im/nim-unittest2@#8b51e99b4a57fcfb31689230e75595f024543024
websock;https://github.com/status-im/nim-websock@#d5cd89062cd2d168ef35193c7d29d2102921d97e
zlib;https://github.com/status-im/nim-zlib@#daa8723fd32299d4ca621c837430c29a5a11e19a
jwt;https://github.com/vacp2p/nim-jwt@#18f8378de52b241f321c1f9ea905456e89b95c6f
bearssl_pkey_decoder;https://github.com/vacp2p/bearssl_pkey_decoder@#21dd3710df9345ed2ad8bf8f882761e07863b8e0
bio;https://github.com/xzeshen/bio@#0f5ed58b31c678920b6b4f7c1783984e6660be97

View File

@@ -10,7 +10,8 @@ skipDirs = @["tests", "examples", "Nim", "tools", "scripts", "docs"]
requires "nim >= 1.6.0",
"nimcrypto >= 0.6.0 & < 0.7.0", "dnsclient >= 0.3.0 & < 0.4.0", "bearssl >= 0.2.5",
"chronicles >= 0.10.3 & < 0.11.0", "chronos >= 4.0.4", "metrics", "secp256k1",
"stew >= 0.4.0", "websock >= 0.2.0", "unittest2", "results", "quic >= 0.2.7"
"stew >= 0.4.0", "websock >= 0.2.0", "unittest2", "results", "quic >= 0.2.7", "bio",
"https://github.com/vacp2p/nim-jwt.git#18f8378de52b241f321c1f9ea905456e89b95c6f"
let nimc = getEnv("NIMC", "nim") # Which nim compiler to use
let lang = getEnv("NIMLANG", "c") # Which backend (c/cpp/js)

327
libp2p/autotls/acme.nim Normal file
View File

@@ -0,0 +1,327 @@
import options, base64, sequtils, serialization, json_serialization
from times import DateTime, parse
import chronos/apps/http/httpclient, jwt, results, bearssl/pem
from std/json import
JsonNode, `%*`, `%`, `[]`, `[]=`, `$`, parseJson, getStr, items, newJObject
import ./utils
import ../crypto/crypto
import ../crypto/rsa
import ../transports/tls/certificate_ffi
import ../transports/tls/certificate
const
LetsEncryptURL* = "https://acme-v02.api.letsencrypt.org"
LetsEncryptURLStaging* = "https://acme-staging-v02.api.letsencrypt.org"
Alg = "RS256"
DefaultChalCompletedRetries = 10
DefaultChalCompletedRetryTime = 1.seconds
DefaultFinalizeRetries = 10
DefaultFinalizeRetryTime = 1.seconds
DefaultRandStringSize = 256
type ACMEDirectory = object
newNonce: string
newOrder: string
newAccount: string
type ACMEAccount* = object
status*: Opt[string]
contact*: Opt[seq[string]]
key*: KeyPair
session*: HttpSessionRef
kid*: Opt[string]
directory: ACMEDirectory
acmeServerURL: string
proc new*(
T: typedesc[ACMEAccount],
key: KeyPair,
status: Opt[string] = Opt.none(string),
contact: Opt[seq[string]] = Opt.none(seq[string]),
kid: Opt[string] = Opt.none(string),
acmeServerURL: string = LetsEncryptURL,
): Future[ref ACMEAccount] {.async: (raises: [ACMEError, CancelledError]).} =
let session = HttpSessionRef.new()
var directory: ACMEDirectory
try:
let directoryResponse =
await HttpClientRequestRef.get(session, acmeServerURL & "/directory").get().send()
directory = (
bytesToString(await directoryResponse.getBodyBytes()).checkedParseJson()
).to(ACMEDirectory)
except HttpError as exc:
raise newException(ACMEError, "Failed to connect to ACME server", exc)
except ValueError as exc:
raise newException(ACMEError, "Unable to parse JSON", exc)
except OSError as exc:
raise newException(ACMEError, "Unable to parse JSON", exc)
except IOError as exc:
raise newException(ACMEError, "Unable to parse JSON", exc)
let acc = new(ACMEAccount)
acc.status = status
acc.contact = contact
acc.kid = kid
acc.key = key
acc.session = session
acc.directory = directory
acc.acmeServerURL = acmeServerURL
return acc
proc newNonce(
self: ref ACMEAccount
): Future[string] {.async: (raises: [ACMEError, CancelledError]).} =
try:
let resp =
await HttpClientRequestRef.get(self.session, self.directory.newNonce).get().send()
return resp.headers.getString("Replay-Nonce")
except HttpError as exc:
raise newException(ACMEError, "Failed to request new nonce from ACME server", exc)
# TODO: save n and e in account so we don't have to recalculate every time
proc acmeHeader(
self: ref ACMEAccount, url: string, needsJwk: bool, kid: string = ""
): Future[JsonNode] {.async: (raises: [ACMEError, CancelledError]).} =
# TODO: check if scheme is RSA
let pubkey = self.key.pubkey.rsakey
let nArray = @(getArray(pubkey.buffer, pubkey.key.n, pubkey.key.nlen))
let eArray = @(getArray(pubkey.buffer, pubkey.key.e, pubkey.key.elen))
let n = base64UrlEncode(nArray)
let e = base64UrlEncode(eArray)
let newNonce = await self.newNonce()
var header = %*{"alg": Alg, "typ": "JWT", "nonce": newNonce, "url": url}
if needsJwk:
header["jwk"] = %*{"kty": "RSA", "n": n, "e": e}
else:
if self.kid.isNone:
raise newException(ACMEError, "no kid registered for account")
header["kid"] = %*(self.kid.get)
return header
proc signedAcmeRequest(
self: ref ACMEAccount, url: string, payload: JsonNode, needsJwk: bool = false
): Future[HttpClientResponseRef] {.async: (raises: [ACMEError, CancelledError]).} =
let acmeHeader = await self.acmeHeader(url, needsJwk)
var token: JWT
var body: JsonNode
try:
token = toJWT(%*{"header": acmeHeader, "claims": payload})
let derPrivKey = self.key.seckey.rsakey.getBytes.get
let pemPrivKey: string = pemEncode(derPrivKey, "PRIVATE KEY")
token.sign(pemPrivKey)
body = token.toFlattenedJson()
except CatchableError as exc:
raise newException(ACMEError, "Failed to create JWT", exc)
try:
return await HttpClientRequestRef
.post(
self.session,
url,
body = $body,
headers = [("Content-Type", "application/jose+json")],
)
.get()
.send()
except HttpError as exc:
raise newException(ACMEError, "Failed to send HTTP request to the ACME server", exc)
proc register*(self: ref ACMEAccount) {.async: (raises: [ACMEError, CancelledError]).} =
if self.kid.isSome:
# already registered
return
let payload = %*{"termsOfServiceAgreed": true}
let response =
await self.signedAcmeRequest(self.directory.newAccount, payload, needsJwk = true)
let jsonResponseBody = await response.getParsedResponseBody()
if response.status != HttpCreated:
raise newException(
ACMEError, "Unable to register with ACME server: " & $jsonResponseBody
)
self.kid = Opt.some(response.headers.getString("location"))
self.status = Opt.some(jsonResponseBody.getJSONField("status").getStr)
proc requestChallenge*(
self: ref ACMEAccount, domains: seq[string]
): Future[(JsonNode, string, string)] {.async: (raises: [ACMEError, CancelledError]).} =
# request challenge from ACME server
var identifiers: seq[JsonNode] = domains.mapIt(%*{"type": "dns", "value": it})
let orderPayload = %*{"identifiers": identifiers}
let challengeResponse =
await self.signedAcmeRequest(self.directory.newOrder, orderPayload)
let challengeResponseBody = await challengeResponse.getParsedResponseBody()
let orderURL = challengeResponse.headers.getString("location")
if orderURL == "":
raise newException(ACMEError, "'location' header not found in ACME response")
let finalizeURL = challengeResponseBody.getJSONField("finalize").getStr
# get challenges
let authzURL = challengeResponseBody.getJSONField("authorizations")[0].getStr
let authzResponseBody =
try:
let authzResponse =
await HttpClientRequestRef.get(self.session, authzURL).get().send()
await authzResponse.getParsedResponseBody()
except CatchableError as exc:
raise newException(ACMEError, "Failed to request challenge", exc)
let challenges = authzResponseBody.getJSONField("challenges")
var dns01: JsonNode = nil
for item in challenges:
if item.getJSONField("type").getStr == "dns-01":
dns01 = item
break
if dns01.isNil:
raise newException(ACMEError, "DNS01 challenge not found in ACME response")
return (dns01, finalizeURL, orderURL)
proc notifyChallengeCompleted*(
self: ref ACMEAccount, chalURL: string, retries: int = DefaultChalCompletedRetries
): Future[void] {.async: (raises: [ACMEError, CancelledError]).} =
let emptyPayload = newJObject()
let completedResponse = await self.signedAcmeRequest(chalURL, emptyPayload)
if completedResponse.status != HttpOk:
raise newException(
ACMEError,
"Failed got HTTP status code " & $completedResponse.status &
" while sending completed message to ACME server",
)
var completedResponseBody: JsonNode
try:
completedResponseBody =
bytesToString(await completedResponse.getBodyBytes()).checkedParseJson()
except HttpError as exc:
raise newException(ACMEError, "Failed to connect to ACME server", exc)
except ValueError as exc:
raise newException(
ACMEError, "Unexpected error while signaling challenge completion", exc
)
let checkURL = completedResponseBody.getJSONField("url").getStr
# check until acme server is done (poll validation)
for _ in 0 .. retries:
var checkResponse: HttpClientResponseRef
var checkResponseBody: JsonNode
try:
checkResponse =
await HttpClientRequestRef.get(self.session, checkURL).get().send()
checkResponseBody =
bytesToString(await checkResponse.getBodyBytes()).checkedParseJson()
except HttpError as exc:
raise newException(ACMEError, "Failed to connect to ACME server", exc)
except ValueError as exc:
raise newException(
ACMEError, "Unexpected error while signaling challenge completion", exc
)
let status = checkResponseBody.getJSONField("status").getStr
case status
of "pending":
var retryAfter: Duration
try:
retryAfter = parseInt(checkResponse.headers.getString("Retry-After")).seconds
except ValueError:
retryAfter = DefaultChalCompletedRetryTime
await sleepAsync(retryAfter) # try again after some delay
of "valid":
return
else:
raise newException(
ACMEError, "Failed challenge completion: expected 'valid', got '" & status & "'"
)
proc finalizeCertificate*(
self: ref ACMEAccount,
domain: string,
finalizeURL: string,
orderURL: string,
retries: int = DefaultFinalizeRetries,
): Future[bool] {.async: (raises: [ACMEError, CancelledError]).} =
var certKey: cert_key_t
var certCtx: cert_context_t
var derCSR: ptr cert_buffer = nil
let personalizationStr = "libp2p_autotls"
if cert_init_drbg(
personalizationStr.cstring, personalizationStr.len.csize_t, certCtx.addr
) != CERT_SUCCESS:
raise newException(ACMEError, "Failed to initialize certCtx")
if cert_generate_key(certCtx, certKey.addr) != CERT_SUCCESS:
raise newException(ACMEError, "Failed to generate cert key")
if cert_signing_req(domain.cstring, certKey, derCSR.addr) != CERT_SUCCESS:
raise newException(ACMEError, "Failed to create CSR")
let b64CSR = base64.encode(derCSR.toSeq, safe = true)
let payload = %*{"csr": b64CSR}
# send finalize request
let finalizedResponse = await self.signedAcmeRequest(finalizeURL, payload)
if finalizedResponse.status != HttpOk:
raise newException(ACMEError, "Failed to request cert finalization")
# keep checking order until it's finalized
var checkResponse: HttpClientResponseRef
var checkResponseBody: JsonNode
for _ in 0 .. retries:
let finalizedResponse = await self.signedAcmeRequest(finalizeURL, payload)
try:
checkResponse =
await HttpClientRequestRef.get(self.session, orderURL).get().send()
checkResponseBody =
bytesToString(await checkResponse.getBodyBytes()).checkedParseJson()
except ValueError as exc:
raise
newException(ACMEError, "JSON parsing error while finalizing certificate", exc)
except CatchableError as exc:
raise
newException(ACMEError, "Unexpected error while finalizing certificate", exc)
let status = checkResponseBody.getJSONField("status").getStr
case status
of "valid":
return true
of "processing":
var retryAfter: Duration
try:
retryAfter = parseInt(checkResponse.headers.getString("Retry-After")).seconds
except ValueError:
retryAfter = DefaultFinalizeRetryTime
await sleepAsync(retryAfter) # try again after some delay
else:
return false
return false
proc downloadCertificate*(
self: ref ACMEAccount, orderURL: string
): Future[(string, DateTime)] {.async: (raises: [ACMEError, CancelledError]).} =
try:
let downloadResponse =
await HttpClientRequestRef.get(self.session, orderURL).get().send()
if downloadResponse.status != HttpOk:
raise newException(ACMEError, "Failed to download certificate")
let certificateInfoBody =
bytesToString(await downloadResponse.getBodyBytes()).checkedParseJson()
let certificateDownloadURL = certificateInfoBody.getJSONField("certificate").getStr
let certificateExpiry = parse(
certificateInfoBody.getJSONField("expires").getStr, "yyyy-MM-dd'T'HH:mm:ss'Z'"
)
let certificateResponse =
await HttpClientRequestRef.get(self.session, certificateDownloadURL).get().send()
let rawCertificate = bytesToString(await certificateResponse.getBodyBytes())
return (rawCertificate, certificateExpiry)
except HttpError:
raise newException(ACMEError, "Failed to connect to ACME server")
except ValueError as exc:
raise newException(ACMEError, "Unexpected error while downloading certificate", exc)

271
libp2p/autotls/autotls.nim Normal file
View File

@@ -0,0 +1,271 @@
# 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
import ./peeridauth
import ./utils
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
DefaultRenewBufferTime = 1.hours
DefaultDnsRetries = 10
DefaultDnsRetryTime = 1.seconds
type SigParam = object
k: string
v: seq[byte]
type AutoTLSManager* = ref object
rng: ref HmacDrbgContext
managerFut: Future[void]
cert*: Opt[TLSCertificate]
certExpiry*: Opt[Moment]
certReady*: AsyncEvent
acmeAccount*: ref ACMEAccount
httpSession: HttpSessionRef
dnsResolver*: DnsResolver
peerInfo*: Opt[PeerInfo]
bearerToken*: Opt[string]
renewCheckTime*: Duration
renewBufferTime*: Duration
acmeServerURL: string
keyAuthorization*: Opt[string]
ipAddress: Opt[IpAddress]
proc new*(
T: typedesc[AutoTLSManager],
rng: ref HmacDrbgContext = newRng(),
acmeAccount: ref ACMEAccount = nil,
dnsResolver: DnsResolver = DnsResolver.new(DefaultDnsServers),
acmeServerURL: string = LetsEncryptURL,
ipAddress: Opt[IpAddress] = Opt.none(IpAddress),
): AutoTLSManager =
T(
rng: rng,
managerFut: nil,
cert: Opt.none(TLSCertificate),
certExpiry: Opt.none(Moment),
certReady: newAsyncEvent(),
acmeAccount: acmeAccount,
httpSession: HttpSessionRef.new(),
dnsResolver: dnsResolver,
peerInfo: Opt.none(PeerInfo),
bearerToken: Opt.none(string),
renewCheckTime: DefaultRenewCheckTime,
renewBufferTime: DefaultRenewBufferTime,
acmeServerURL: acmeServerURL,
keyAuthorization: Opt.none(string),
ipAddress: ipAddress,
)
proc checkDNSRecords(
self: AutoTLSManager,
ip4Domain: string,
acmeChalDomain: string,
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 self.keyAuthorization.isSome and
txt[0] == self.keyAuthorization.get() 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 (dns01Challenge, finalizeURL, orderURL) =
await self.acmeAccount.requestChallenge(@[domain])
self.keyAuthorization = Opt.some(
base64UrlEncode(
@(
sha256.digest(
(
dns01Challenge.getJSONField("token").getStr & "." &
thumbprint(self.acmeAccount.key)
).toByteSeq
).data
)
)
)
let strMultiaddresses: seq[string] = peerInfo.addrs.mapIt($it)
let payload = %*{"value": self.keyAuthorization.get(), "addresses": strMultiaddresses}
let registrationURL = "https://" & AutoTLSBroker & "/v1/_acme-challenge"
trace "Sending challenge to AutoTLS broker"
var response: HttpClientResponseRef
var bearerToken: string
if self.bearerToken.isSome:
(bearerToken, response) = await peerIdAuthSend(
registrationURL,
self.httpSession,
peerInfo,
payload,
self.rng,
bearerToken = self.bearerToken,
)
else:
# authenticate, send challenge and save bearerToken for future requests
(bearerToken, response) = await peerIdAuthSend(
registrationURL, self.httpSession, peerInfo, payload, self.rng
)
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):
raise newException(AutoTLSError, "DNS records not set")
debug "Notifying challenge completion to ACME server"
let chalURL = dns01Challenge.getJSONField("url").getStr
await self.acmeAccount.notifyChallengeCompleted(chalURL)
debug "Finalize cert request with CSR"
if not await self.acmeAccount.finalizeCertificate(domain, finalizeURL, orderURL):
raise newException(AutoTLSError, "ACME certificate finalization request failed")
debug "Downloading certificate"
let (rawCert, expiry) = await self.acmeAccount.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 account"
if self.acmeAccount.isNil:
let accountKey = KeyPair.random(PKScheme.RSA, self.rng[]).get()
self.acmeAccount =
(await ACMEAccount.new(accountKey, acmeServerURL = self.acmeServerURL))
await self.acmeAccount.register()
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.renewBufferTime
if waitTime <= self.renewBufferTime:
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.managerFut.isNil:
warn "Starting AutoTLS twice"
return
self.peerInfo = Opt.some(peerInfo)
self.managerFut = self.manageCertificate()
method stop*(self: AutoTLSManager): Future[void] {.base, async: (raises: []).} =
trace "AutoTLS stop"
if self.managerFut.isNil:
warn "Stopping AutoTLS without starting it"
return
await self.managerFut.cancelAndWait()
self.managerFut = nil
if not self.acmeAccount.isNil:
await self.acmeAccount.session.closeWait()
if not self.httpSession.isNil:
await self.httpSession.closeWait()

View File

@@ -0,0 +1,229 @@
import base64, json, strutils
import chronos/apps/http/httpclient, results, chronicles, bio
import ./utils, ../peerinfo, ../crypto/crypto
logScope:
topics = "libp2p peerid auth"
const PeerIDAuthPrefix = "libp2p-PeerID"
const ChallengeCharset =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const ChallengeDefaultLen = 48
type SigParam = object
k: string
v: seq[byte]
proc randomChallenge(
rng: ref HmacDrbgContext, challengeLen: int = ChallengeDefaultLen
): string =
var rng = rng[]
var challenge = ""
for _ in 0 ..< challengeLen:
challenge.add(rng.sampleChar(ChallengeCharset))
challenge
proc extractField(data, key: string): string {.raises: [PeerIDAuthError].} =
# Helper to extract quoted value from key
for segment in data.split(","):
if key in segment:
return segment.split("=", 1)[1].strip(chars = {' ', '"'})
raise newException(PeerIDAuthError, "Failed to find " & key & " in " & data)
proc encodeVarint(n: int): seq[byte] =
var varInt: seq[byte] = @[]
var x = uint64(n)
while x != 0:
var byteVal = byte(x and 0x7F)
x = x shr 7
if x != 0:
byteVal = byteVal or 0x80
varInt.add(byteVal)
return varInt
proc genDataToSign(prefix: string, parts: seq[SigParam]): seq[byte] =
var buf: seq[byte] = prefix.toByteSeq()
for p in parts:
buf.add encodeVarint(p.k.len + p.v.len + 1)
buf.add (p.k & "=").toByteSeq()
buf.add p.v
return buf
proc getSigParams(
clientSender: bool, hostname: string, challenge: string, publicKey: PublicKey
): seq[SigParam] =
if clientSender:
@[
SigParam(k: "challenge-client", v: challenge.toByteSeq()),
SigParam(k: "hostname", v: hostname.toByteSeq()),
SigParam(k: "server-public-key", v: publicKey.getBytes().get()),
]
else:
@[
SigParam(k: "challenge-server", v: challenge.toByteSeq()),
SigParam(k: "client-public-key", v: publicKey.getBytes().get()),
SigParam(k: "hostname", v: hostname.toByteSeq()),
]
proc peerIdSign(
privateKey: PrivateKey,
challenge: string,
publicKey: PublicKey,
hostname: string,
clientSender: bool = true,
): string =
let parts = getSigParams(clientSender, hostname, challenge, publicKey)
let bytesToSign = genDataToSign(PeerIDAuthPrefix, parts)
return base64.encode(privateKey.sign(bytesToSign).get().getBytes(), safe = true)
proc checkSignature(
serverSig: string,
serverPublicKey: PublicKey,
challengeServer: string,
clientPublicKey: PublicKey,
hostname: string,
): bool {.raises: [PeerIDAuthError].} =
let parts = getSigParams(false, hostname, challengeServer, clientPublicKey)
let signedBytes = genDataToSign(PeerIDAuthPrefix, parts)
var serverSignature: Signature
try:
if not serverSignature.init(base64.decode(serverSig).toByteSeq()):
raise newException(
PeerIDAuthError, "Failed to initialize Signature from base64 encoded sig"
)
except ValueError as exc:
raise newException(PeerIDAuthError, "Failed to decode server's signature", exc)
return serverSignature.verify(
signedBytes.toOpenArray(0, signedBytes.len - 1), serverPublicKey
)
proc peerIdAuthenticate(
url: string,
session: HttpSessionRef,
peerInfo: PeerInfo,
payload: JsonNode,
rng: ref HmacDrbgContext,
): Future[(string, HttpClientResponseRef)] {.
async: (raises: [AutoTLSError, PeerIDAuthError, CancelledError])
.} =
# Authenticate in three ways as per the PeerID Auth spec
# https://github.com/libp2p/specs/blob/master/http/peer-id-auth.md
# request authentication
let base36PeerId = encodePeerId(peerInfo.peerId)
var authStartResponse: HttpClientResponseRef
try:
authStartResponse = await HttpClientRequestRef.get(session, url).get().send()
except HttpError as exc:
raise newException(PeerIDAuthError, "Failed to start PeerID Auth", exc)
# www-authenticate
let wwwAuthenticate = authStartResponse.headers.getString("WWW-Authenticate")
if wwwAuthenticate == "":
raise newException(PeerIDAuthError, "WWW-authenticate not present in response")
let challengeClient = extractField(wwwAuthenticate, "challenge-client")
let serverPublicKey: PublicKey =
try:
PublicKey.init(decode(extractField(wwwAuthenticate, "public-key")).toByteSeq()).valueOr:
raise newException(PeerIDAuthError, "Failed to initialize public-key")
except ValueError as exc:
raise newException(PeerIDAuthError, "Failed to decode public-key", exc)
let opaque = extractField(wwwAuthenticate, "opaque")
let publicKeyBytes: seq[byte] =
try:
peerInfo.publicKey.getBytes().valueOr:
raise
newException(PeerIDAuthError, "Failed to get bytes from PeerInfo's publicKey")
except ValueError as exc:
raise newException(
PeerIDAuthError, "Failed to get bytes from PeerInfo's publicKey", exc
)
let hostname = AutoTLSBroker # registration.libp2p.direct
let clientPubKeyB64 = base64.encode(publicKeyBytes, safe = true)
let challengeServer: string =
try:
randomChallenge(rng)
except ValueError as exc:
raise newException(PeerIDAuthError, "Failed to generate challenge", exc)
let sig = peerIdSign(peerInfo.privateKey, challengeClient, serverPublicKey, hostname)
let authHeader =
PeerIDAuthPrefix & " public-key=\"" & clientPubKeyB64 & "\"" & ", opaque=\"" & opaque &
"\"" & ", challenge-server=\"" & challengeServer & "\"" & ", sig=\"" & sig & "\""
# Authorization
let authorizationResponse: HttpClientResponseRef =
try:
await HttpClientRequestRef
.post(
session,
url,
body = $payload,
headers = [
("Content-Type", "application/json"),
("User-Agent", "nim-libp2p"),
("authorization", authHeader),
],
)
.get()
.send()
except HttpError as exc:
raise newException(
PeerIDAuthError, "Failed to send Authorization for PeerID Auth", exc
)
# check server's signature
let serverSig =
extractField(authorizationResponse.headers.getString("authentication-info"), "sig")
if not checkSignature(
serverSig, serverPublicKey, challengeServer, peerInfo.publicKey, hostname
):
raise newException(PeerIDAuthError, "Failed to validate server's signature")
# Bearer token
return (
extractField(
authorizationResponse.headers.getString("authentication-info"), "bearer"
),
authorizationResponse,
)
proc peerIdAuthSend*(
url: string,
session: HttpSessionRef,
peerInfo: PeerInfo,
payload: JsonNode,
rng: ref HmacDrbgContext,
bearerToken: Opt[string] = Opt.none(string),
): Future[(string, HttpClientResponseRef)] {.
async: (raises: [AutoTLSError, PeerIDAuthError, CancelledError])
.} =
if bearerToken.isNone:
return await peerIdAuthenticate(url, session, peerInfo, payload, rng)
let authHeader = PeerIDAuthPrefix & " bearer=\"" & bearerToken.get & "\""
var response: HttpClientResponseRef
try:
response = await HttpClientRequestRef
.post(
session,
url,
body = $payload,
headers = [
("Content-Type", "application/json"),
("User-Agent", "nim-libp2p"),
("Authorization", authHeader),
],
)
.get()
.send()
except HttpError as exc:
raise newException(
PeerIDAuthError, "Failed to send request with bearer token for PeerID Auth", exc
)
return (bearerToken.get, response)

109
libp2p/autotls/utils.nim Normal file
View File

@@ -0,0 +1,109 @@
import
base64, strutils, stew/base36, chronos/apps/http/httpclient, chronos, json, net, times
import
../errors, ../peerid, ../multihash, ../cid, ../multicodec, ../crypto/[crypto, rsa]
type
GetPrimaryIPError* = object of LPError
AutoTLSError* = object of LPError
ACMEError* = object of AutoTLSError
PeerIDAuthError* = object of AutoTLSError
const
AutoTLSBroker* = "registration.libp2p.direct"
AutoTLSDNSServer* = "libp2p.direct"
HttpOk* = 200
HttpCreated* = 201
proc sampleChar*(ctx: var HmacDrbgContext, choices: string): char =
## Samples a random character from the input string using the DRBG context
if choices.len == 0:
raise newException(ValueError, "Cannot sample from an empty string")
var idx: uint32
ctx.generate(idx)
return choices[uint32(idx mod uint32(choices.len))]
proc base64UrlEncode*(data: seq[byte]): string =
## Encodes data using base64url (RFC 4648 §5) — no padding, URL-safe
var encoded = base64.encode(data, safe = true)
encoded.removeSuffix("=")
encoded.removeSuffix("=")
return encoded
proc isPublicIPv4*(ip: IpAddress): bool =
if ip.family != IpAddressFamily.IPv4:
return false
let ip = $ip
return
not (
ip.startsWith("10.") or
(ip.startsWith("172.") and parseInt(ip.split(".")[1]) in 16 .. 31) or
ip.startsWith("192.168.") or ip.startsWith("127.") or ip.startsWith("169.254.")
)
proc asMoment*(dt: DateTime): Moment =
let unixTime: int64 = dt.toTime.toUnix
return Moment.init(unixTime, Second)
proc encodePeerId*(peerId: PeerId): string {.raises: [AutoTLSError].} =
var mh: MultiHash
let decodeResult = MultiHash.decode(peerId.data, mh)
if decodeResult.isErr or decodeResult.get() == -1:
raise
newException(AutoTLSError, "Failed to decode PeerId: invalid multihash format")
let cidResult = Cid.init(CIDv1, multiCodec("libp2p-key"), mh)
if cidResult.isErr:
raise newException(AutoTLSError, "Failed to initialize CID from multihash")
return Base36.encode(cidResult.get().data.buffer)
proc checkedParseJson*(bodyBytes: string): JsonNode {.raises: [ValueError].} =
# This is so that we don't need to catch Exceptions directly
# since we support 1.6.16 and parseJson before nim 2 didn't have explicit .raises. pragmas
try:
return parseJson(bodyBytes)
except Exception as exc:
raise newException(ValueError, "Error while parsing JSON", exc)
proc checkedGetPrimaryIPAddr*(): IpAddress {.raises: [GetPrimaryIPError].} =
# This is so that we don't need to catch Exceptions directly
# since we support 1.6.16 and getPrimaryIPAddr before nim 2 didn't have explicit .raises. pragmas
try:
return getPrimaryIPAddr()
except Exception as exc:
raise newException(GetPrimaryIPError, "Error while getting primary IP address", exc)
proc getJSONField*(node: JsonNode, field: string): JsonNode {.raises: [ACMEError].} =
try:
return node[field]
except CatchableError:
raise newException(ACMEError, "'" & field & "' field not found in JSON")
proc getParsedResponseBody*(
response: HttpClientResponseRef
): Future[JsonNode] {.async: (raises: [ACMEError]).} =
try:
let responseBody = bytesToString(await response.getBodyBytes()).checkedParseJson()
return responseBody
except ValueError as exc:
raise newException(ACMEError, "Unable to parse JSON body", exc)
except OSError as exc:
raise newException(ACMEError, "Unable to parse JSON body", exc)
except IOError as exc:
raise newException(ACMEError, "Unable to parse JSON body", exc)
except CatchableError as exc:
raise
newException(ACMEError, "Unexpected error occurred while getting body bytes", exc)
proc thumbprint*(key: KeyPair): string =
doAssert key.seckey.scheme == PKScheme.RSA, "unsupported keytype"
let pubkey = key.pubkey.rsakey
let nArray = @(getArray(pubkey.buffer, pubkey.key.n, pubkey.key.nlen))
let eArray = @(getArray(pubkey.buffer, pubkey.key.e, pubkey.key.elen))
let n = base64UrlEncode(nArray)
let e = base64UrlEncode(eArray)
let keyJson = %*{"e": e, "kty": "RSA", "n": n}
let digest = sha256.digest($keyJson)
return base64UrlEncode(@(digest.data))

View File

@@ -30,6 +30,8 @@ import
connmanager,
upgrademngrs/muxedupgrade,
observedaddrmanager,
autotls/acme,
autotls/autotls,
nameresolving/nameresolver,
errors,
utility
@@ -63,6 +65,7 @@ type
nameResolver: NameResolver
peerStoreCapacity: Opt[int]
autonat: bool
autoTLSMgr: AutoTLSManager
circuitRelay: Relay
rdv: RendezVous
services: seq[Service]
@@ -184,6 +187,13 @@ proc withMemoryTransport*(b: SwitchBuilder): SwitchBuilder {.public.} =
MemoryTransport.new(upgr)
)
# proc withAutoTLSManager*(
# b: SwitchBuilder, acmeServerURL = LetsEncryptURL, ipAddress = Opt.none(IpAddress)
# ): SwitchBuilder {.public.} =
# b.autoTLSMgr =
# AutoTLSManager.new(b.rng, acmeServerURL = acmeServerURL, ipAddress = ipAddress)
# b
proc withRng*(b: SwitchBuilder, rng: ref HmacDrbgContext): SwitchBuilder {.public.} =
b.rng = rng
b
@@ -316,6 +326,7 @@ proc build*(b: SwitchBuilder): Switch {.raises: [LPError], public.} =
secureManagers = secureManagerInstances,
connManager = connManager,
ms = ms,
autoTLSMgr = b.autoTLSMgr,
nameResolver = b.nameResolver,
peerStore = peerStore,
services = b.services,

View File

@@ -20,6 +20,7 @@ import chronos, chronicles, metrics
import
stream/connection,
transports/transport,
autotls/autotls,
upgrademngrs/upgrade,
multistream,
multiaddress,
@@ -58,6 +59,7 @@ type
acceptFuts: seq[Future[void]]
dialer*: Dial
peerStore*: PeerStore
autoTLSMgr*: AutoTLSManager
nameResolver*: NameResolver
started: bool
services*: seq[Service]
@@ -331,6 +333,9 @@ proc stop*(s: Switch) {.public, async: (raises: [CancelledError]).} =
except CatchableError as exc:
warn "error cleaning up transports", description = exc.msg
if not s.autoTLSMgr.isNil:
await s.autoTLSMgr.stop()
await s.ms.stop()
trace "Switch stopped"
@@ -369,6 +374,8 @@ proc start*(s: Switch) {.public, async: (raises: [CancelledError, LPError]).} =
await s.peerInfo.update()
await s.ms.start()
if not s.autoTLSMgr.isNil:
await s.autoTLSMgr.start(s.peerInfo)
s.started = true
debug "Started libp2p node", peer = s.peerInfo
@@ -380,6 +387,7 @@ proc newSwitch*(
connManager: ConnManager,
ms: MultistreamSelect,
peerStore: PeerStore,
autoTLSMgr: AutoTLSManager = nil,
nameResolver: NameResolver = nil,
services = newSeq[Service](),
): Switch {.raises: [LPError].} =
@@ -394,6 +402,7 @@ proc newSwitch*(
peerStore: peerStore,
dialer:
Dialer.new(peerInfo.peerId, connManager, peerStore, transports, nameResolver),
autoTLSMgr: autoTLSMgr,
nameResolver: nameResolver,
services: services,
)

135
tests/testautotls.nim Normal file
View File

@@ -0,0 +1,135 @@
{.used.}
# Nim-Libp2p
# Copyright (c) 2023 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 std/[strformat, net] # uncomment after re-enabling AutoTLSManager
import chronos
import chronos/apps/http/httpclient
import
../libp2p/[
stream/connection,
transports/tcptransport,
upgrademngrs/upgrade,
multiaddress,
switch,
builders,
# autotls/autotls, # uncomment after re-enabling AutoTLSManager
autotls/acme,
autotls/utils,
# nameresolving/dnsresolver, # uncomment after re-enabling AutoTLSManager
wire,
]
import ./helpers
suite "AutoTLS":
teardown:
checkTrackers()
asyncTest "test ACME":
let acc = await ACMEAccount.new(
KeyPair.random(PKScheme.RSA, newRng()[]).get(),
acmeServerURL = LetsEncryptURLStaging,
)
await acc.register()
# account was registered (kid set)
check acc.kid.isSome
# challenge requested
let (dns01Challenge, finalizeURL, orderURL) =
await acc.requestChallenge(@["some.dummy.domain.com"])
check dns01Challenge.isNil == false
check finalizeURL.len > 0
check orderURL.len > 0
await noCancel(acc.session.closeWait())
# enable when withAutoTLSManager is re-enabled
# asyncTest "test AutoTLSManager":
# let switch = SwitchBuilder
# .new()
# .withRng(newRng())
# .withAddress(MultiAddress.init("/ip4/0.0.0.0/tcp/0").tryGet())
# .withTcpTransport()
# .withAutoTLSManager(acmeServerURL = LetsEncryptURLStaging)
# .withYamux()
# .withNoise()
# .build()
# try:
# let hostPrimaryIP = getPrimaryIPAddr()
# if not isPublicIPv4(hostPrimaryIP):
# skip() # host doesn't have public IPv4 address
# return
# except Exception:
# skip() # can't get primary IPv4 address from host
# return
# # this is so that we can check renewal afterwards
# switch.autoTLSMgr.renewCheckTime = 3.seconds
# await switch.start()
# # wait for cert to be ready
# await switch.autoTLSMgr.certReady.wait()
# # clear since we'll use it again
# switch.autoTLSMgr.certReady.clear()
# # challenge was sent (bearer token from peer id auth was set)
# check switch.autoTLSMgr.bearerToken.isSome
# let dnsResolver = DnsResolver.new(
# @[
# initTAddress("1.1.1.1:53"),
# initTAddress("1.0.0.1:53"),
# initTAddress("[2606:4700:4700::1111]:53"),
# ]
# )
# let base36PeerId = encodePeerId(switch.peerInfo.peerId)
# let dnsTXTRecord = (
# await dnsResolver.resolveTxt(
# fmt"_acme-challenge.{base36PeerId}.{AutoTLSDNSServer}"
# )
# )[0]
# # DNS TXT record is set
# let keyAuthorization = switch.autoTLSMgr.keyAuthorization.valueOr:
# raiseAssert "keyAuthorization not found"
# check dnsTXTRecord == keyAuthorization
# # certificate was downloaded and parsed
# let cert = switch.autoTLSMgr.cert.valueOr:
# raiseAssert "certificate not found"
# let certBefore = cert
# # invalidate certificate
# switch.autoTLSMgr.certExpiry = Opt.some(Moment.now - 2.hours)
# # cert was invalidated correctly
# check switch.autoTLSMgr.certExpiry.get < Moment.now
# # wait for cert to be renewed
# await switch.autoTLSMgr.certReady.wait()
# # certificate was indeed renewed
# let certAfter = switch.autoTLSMgr.cert.valueOr:
# raiseAssert "certificate not found"
# check certBefore != certAfter
# let certExpiry = switch.autoTLSMgr.certExpiry.valueOr:
# raiseAssert "certificate expiry not found"
# # cert is valid
# check certExpiry > Moment.now
# await switch.stop()

View File

@@ -28,7 +28,7 @@ import
transports/tls/testcertificate
import
testnameresolve, testmultistream, testbufferstream, testidentify,
testautotls, testnameresolve, testmultistream, testbufferstream, testidentify,
testobservedaddrmanager, testconnmngr, testswitch, testnoise, testpeerinfo,
testpeerstore, testping, testmplex, testrelayv1, testrelayv2, testrendezvous,
testdiscovery, testyamux, testautonat, testautonatservice, testautorelay, testdcutr,