Validace certifikátů Let's Encrypt pomocí DNS včetně wildcard

10. 12. 2019
Doba čtení: 5 minut

Sdílet

Ilustrační obrázek
Autor: Depositphotos – stori
Ilustrační obrázek
Drtivá většina klientů ověřuje certifikáty u Let's Encrypt pomocí HTTP. Někdy ovšem tuhle možnost nechceme či nemůžeme použít, pak přichází na řadu validace pomocí DNS. Popíšeme si postup na vlastní DNS infrastruktuře.

Certifikační autorita Let's Encrypt je velmi populárním řešením pro pohodlné získávání certifikátů. Jednak nabízí tvorbu certifikátů zdarma, ale především je vše možné provést plně automaticky. Celý proces je tak možné udělat spuštěním jednoho příkazu, navíc některé aplikace mají podporu integrovanou a vše proběhne prakticky bez uživatelova zásahu.

Během získávání podepsaného certifikátu je potřeba projít procesem validace. Při něm musí žadatel prokázat, že má možnost manipulovat s doménou či obsahem na ní. Tradiční metoda spočívá v tom, že se na web serveru na správné cestě vystaví výzva, kterou si pak autorita stáhne. Je to nejjednodušší a není k tomu potřeba nic, kromě web serveru.

Existuje ale řada případů, kdy není možné tuto metodu použít. Můžeme mít například centrální server pro získávání certifikátů, který vše vyřídí za všechny naše služby a poté jen rozdistribuuje aktuální certifikáty. Nebo chceme získat certifikáty pro zařízení, na kterých nemůžeme žádného ACME klienta spouštět – třeba pro síťové prvky. Dalším důvodem může být potřeba získat wildcard cerfitikáty, které je možné validovat jen pomocí DNS.

Validace s proxy DNS

Pokud chceme žádost ověřit pomocí DNS, musíme řetězce dodané autoritou vystavit v DNS. Konkrétně se jedná o záznamy typu TXT pro subdoménu _acme-challenge. Samozřejmě to vyžaduje, abychom měli v danou chvíli automatizovaný přístup k zóně a mohli ji editovat.

O první možnosti psal před časem Dan Ohnesorg, můžeme část zóny přesměrovat k poskytovateli, který nám nabízí API. Není vůbec potřeba předávat kontrolu nad doménou jako celkem, stačí nám jen přesměrovat zmíněnou subdoménu. Do zóny na svých serverech tak přidáme následující záznam:

_acme-challenge.example.com. IN CNAME _acme-challenge.domena-s-api.cz.

Poté už použijeme klienta, který je schopen pomocí API přidávat požadované TXT záznamy. Stále to ale vyžaduje použít cizí API a navíc potřebujeme mít navíc jednu zbytečnou doménu, kterou takto využijeme jen pro server s API.

DNS na naší infrastruktuře

Pokud máme vlastní infrastrukturu pro DNS a máme tedy své autoritativní servery, nebudeme tohle všechno vůbec používat a můžeme přímo měnit záznamy v zóně pomocí dynamických updatů dle RFC 2136. Ty nám dovolují zasahovat vzdáleně do zóny, aniž bychom museli mít přímý přístup do zónových souborů.

Budeme potřebovat úpravu konfigurace autoritativního serveru a ACME klienta, který podporuje nsupdate. V tomto příkladu použijeme skript ACME.sh, ale vy můžete použít svého oblíbeného klienta.

Pro autorizaci použijeme TSIG (Transaction SIGnature), což je standardní způsob podepisování DNS přenosů definovaný v RFC 2845. Zjednodušeně řečeno: obě strany znají sdílené tajemství (klíč), kterým je komunikace zabezpečena. Bez znalosti tohoto tajemství není možné do zóny na dálku zasahovat.

Konfigurace DNS serveru

Předpokládám, že už nám autoritativní DNS servery běží a máme na nich platné zónové soubory. Nejprve musíme vygenerovat klíč pro TSIG. Použijeme k tomu utilitu dnssec-keygen z balíčku  bind9utils.

$ dnssec-keygen -a hmac-sha256 -b 256 -n HOST acme-tsig.example.com
Kacme-tsig.example.com.+163+33240
$ cat Kacme-tsig.example.com.+163+33240.key
acme-tsig.example.com. IN KEY 512 3 163 /GQWpBMMsl5QnYxNaHfCqj5MzURSroW6lgYJqRtN/L0=

Klíč vložíme do konfigurace serveru a povolíme jeho držiteli aktualizovat záznamy v zóně. Pozor na to, že jméno klíče je součástí podepisované zprávy a musí být tedy nastavené na stejnou hodnotu na serveru, jako v klientovi (utilitě nsupdate, kterou budeme provádět vlastní změny v zóně). Na vlastním jménu klíče nezáleží, abychom ale předešli zbytečným kolizím, je dobrou praxí použít libovolné doménové jméno, které máme pod kontrolou.

Pokud používáme BIND, bude konfigurace vypadat takto:

key "acme-tsig.example.com." {
  algorithm hmac-sha256;
  secret "/GQWpBMMsl5QnYxNaHfCqj5MzURSroW6lgYJqRtN/L0=";
};

zone "example.com." IN {
  type master;
  file "named.example.com";
  update-policy {
    grant acme-tsig.example.com. name _acme-challenge.example.com. txt;
    grant acme-tsig.example.com. name _acme-challenge.www.example.com. txt;
  };
};

Pokud používáte Knot DNS, bude stejné nastavení vypadat takto:

key:
   - id: "acme-tsig.example.com"
     algorithm: "hmac-sha256"
     secret: "/GQWpBMMsl5QnYxNaHfCqj5MzURSroW6lgYJqRtN/L0="

acl:
   - id: acme_update
     key: "acme-tsig.example.com"
     action: update
     update-type: TXT
     update-owner: name
     update-owner-name: [_acme-challenge.example.com., _acme-challenge.www.example.com.]
     update-owner-match: equal

zone:
   - domain: "example.com"
     acl: acme_update
     …

To je z hlediska serveru vše, klient se znalostí sdíleného tajemství je teď schopen měnit patřičné záznamy.

Získání certifikátu

V první řadě budeme potřebovat klienta, který dokáže komunikovat s certifikační autoritou a pomocí protokolu ACME vyjednat získání certifikátu. Nainstalujeme si zmíněný skript ACME.sh:

$ git clone https://github.com/Neilpang/acme.sh.git
$ cd acme.sh
$ ./acme.sh --install

Před spuštěním klienta musíme nastavit dvě proměnné prostředí, které ho nasměrují na správný DNS server a soubor s vygenerovaným sdíleným tajemstvím pro autorizaci. To je potřeba udělat jen na začátku, klient si pak údaje uloží do konfiguračního souboru ~/.acme.sh/account.conf a příště je použije sám.

$ export NSUPDATE_SERVER="ns-master.example.com"
$ export NSUPDATE_KEY="/home/letsencrypt/Kacme-tsig.example.com.+163+33240.key"

Teď už můžeme požádat o certifikát pro naše doménová jména. Klient má všechny informace potřebné k tomu, aby podal žádost a pomocí změny TXT záznamu v DNS nechal autoritu ověřit její oprávněnost.

$ acme.sh --issue --dns dns_nsupdate -d example.com -d www.example.com --key-file /home/letsencrypt/certs/example.com.key --fullchain-file /home/letsencrypt/certs/example.com.cer

V příslušných souborech se nám objeví privátní klíč a důvěryhodný certifikát. Ten už teď stačí jen nasadit v našem web serveru nebo v jiné službě, která jej bude používat. Pokud bychom potřebovali wildcard certifikát, stačí použít parametr  -d *.example.com.

docker + kubernetes školení s dotací tip

Pozor na wildcard DNS

Při ověřování pomocí DNS nás může nepříjemně zaskočit způsob, jakým jsou v DNS implementovány wildcard záznamy, tedy takové záznamy, které začínají *.. Ty z definice dokáží nahradit libovolný neexistující DNS záznam, takže doména vypadá, že obsahuje všechny myslitelné subdomény.

Pokud ovšem do domény zavedeme například záznam _acme-challenge.www.example.com, přestane být záznam www.example.com neexistujícím záznamem (stane se z něj takzvaný empty-non-terminal), takže se na něj přestane hvězdička uplatňovat. Aby vše fungovalo podle očekávání, je potřeba každé jméno, které chceme validovat, zavést explicitně a na expanzi žolíku v takovém případě nespoléhat.

Autor článku

Petr Krčmář pracuje jako šéfredaktor serveru Root.cz. Studoval počítače a média, takže je rozpolcen mezi dva obory. Snaží se dělat obojí, jak nejlépe umí.

Ondřej Caletka vystudoval obor Telekomunikační technika na ČVUT a dnes pracuje ve vzdělávacím oddělení RIPE NCC, mezinárodní asociaci koordinující internetové sítě.

'; document.getElementById('outstream-iframe').onload = function () { setupIframe(); } replayScreen = document.getElementById('iinfoOutstreamReplay'); iinfoOutstreamPosition = document.getElementById('iinfoOutstreamPosition'); outstreamContainer = document.getElementsByClassName('outstream-container')[0]; setupReplayScreen(); } function setupIframe() { outstreamDocument = document.getElementById('outstream-iframe').contentWindow.document; let el = outstreamDocument.createElement('style'); outstreamDocument.head.appendChild(el); el.innerText = "#adContainer>div:nth-of-type(1),#adContainer>div:nth-of-type(1) > iframe { width: 99% !important;height: 99% !important;max-width: 100%;}#videoContent,body{ width:100vw;height:100vh}body{ font-family:'Helvetica Neue',Arial,sans-serif}#videoContent{ overflow:hidden;background:#000}#adMuteBtn{ width:35px;height:35px;border:0;background:0 0;display:none;position:absolute;fill:rgba(230,230,230,1);bottom:-5px;right:25px}"; videoContent = outstreamDocument.getElementById('contentElement'); videoContent.style.display = 'none'; videoContent.volume = 1; videoContent.muted = false; if ( location.href.indexOf('rejstriky.finance.cz') !== -1 || location.href.indexOf('finance-rejstrik') !== -1 || location.href.indexOf('firmy.euro.cz') !== -1 || location.href.indexOf('euro-rejstrik') !== -1 || location.href.indexOf('/rejstrik/') !== -1 || location.href.indexOf('/rejstrik-firem/') !== -1) { outstreamDirectPlayed = true; soundAllowed = true; iinfoVastUrlIndex = 0; } if (!outstreamDirectPlayed) { console.log('OUTSTREAM direct'); setUpIMA(true); } else { if (soundAllowed) { const playPromise = videoContent.play(); if (playPromise !== undefined) { playPromise.then(function () { console.log('OUTSTREAM sound allowed'); setUpIMA(false); }).catch(function () { console.log('OUTSTREAM sound forbidden'); renderBanner(); }); } } else { renderBanner(); } } } function getWrapper() { let articleWrapper = document.querySelector('.rs-outstream-placeholder'); // Outstream Placeholder from RedSys manipulation if (articleWrapper && articleWrapper.style.display !== 'block') { articleWrapper.innerHTML = ""; articleWrapper.style.display = 'block'; } // Don't render OutStream on homepages if (articleWrapper === null) { if (document.querySelector('body.p-index')) { return null; } } if (articleWrapper === null) { articleWrapper = document.getElementById('iinfo-outstream'); } if (articleWrapper === null) { articleWrapper = document.querySelector('.layout-main__content .detail__article p:nth-of-type(6)'); } if (articleWrapper === null) { // Euro, Autobible, Zdravi articleWrapper = document.querySelector('.o-article .o-article__text p:nth-of-type(6)'); } if (articleWrapper === null) { articleWrapper = document.getElementById('sidebar'); } if (!articleWrapper) { console.error("Outstream wrapper of article was not found."); } return articleWrapper; } function setupDimensions() { outstreamWidth = Math.min(iinfoOutstreamPosition.offsetWidth, 480); outstreamHeight = Math.min(iinfoOutstreamPosition.offsetHeight, 320); } /** * Sets up IMA ad display container, ads loader, and makes an ad request. */ function setUpIMA(direct) { google.ima.settings.setDisableCustomPlaybackForIOS10Plus(true); google.ima.settings.setLocale('cs'); google.ima.settings.setNumRedirects(10); // Create the ad display container. createAdDisplayContainer(); // Create ads loader. adsLoader = new google.ima.AdsLoader(adDisplayContainer); // Listen and respond to ads loaded and error events. adsLoader.addEventListener( google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, onAdsManagerLoaded, false); adsLoader.addEventListener( google.ima.AdErrorEvent.Type.AD_ERROR, onAdError, false); // An event listener to tell the SDK that our content video // is completed so the SDK can play any post-roll ads. const contentEndedListener = function () { adsLoader.contentComplete(); }; videoContent.onended = contentEndedListener; // Request video ads. const adsRequest = new google.ima.AdsRequest(); if (direct) { adsRequest.adTagUrl = directVast; console.log('Outstream DIRECT CAMPAING advert: ' + directVast); videoContent.muted = true; videoContent.volume = 0; outstreamDirectPlayed = true; } else { adsRequest.adTagUrl = iinfoVastUrls[iinfoVastUrlIndex]; console.log('Outstream advert: ' + iinfoVastUrls[iinfoVastUrlIndex]); videoContent.muted = false; videoContent.volume = 1; } // Specify the linear and nonlinear slot sizes. This helps the SDK to // select the correct creative if multiple are returned. // adsRequest.linearAdSlotWidth = outstreamWidth; // adsRequest.linearAdSlotHeight = outstreamHeight; adsRequest.nonLinearAdSlotWidth = 0; adsRequest.nonLinearAdSlotHeight = 0; adsLoader.requestAds(adsRequest); } function setupReplayScreen() { replayScreen.addEventListener('click', function () { iinfoOutstreamPosition.remove(); iinfoVastUrlIndex = 0; outstreamInit(); }); } /** * Sets the 'adContainer' div as the IMA ad display container. */ function createAdDisplayContainer() { // We assume the adContainer is the DOM id of the element that will house // the ads. outstreamDocument.getElementById('videoContent').style.display = 'none'; adDisplayContainer = new google.ima.AdDisplayContainer( outstreamDocument.getElementById('adContainer'), videoContent); } function unmuteAdvert() { adVolume = !adVolume; if (adVolume) { adsManager.setVolume(0.3); outstreamDocument.getElementById('adMuteBtn').innerHTML = ''; } else { adsManager.setVolume(0); outstreamDocument.getElementById('adMuteBtn').innerHTML = ''; } } /** * Loads the video content and initializes IMA ad playback. */ function playAds() { // Initialize the container. Must be done through a user action on mobile // devices. videoContent.load(); adDisplayContainer.initialize(); // setupDimensions(); try { // Initialize the ads manager. Ad rules playlist will start at this time. adsManager.init(1920, 1080, google.ima.ViewMode.NORMAL); // Call play to start showing the ad. Single video and overlay ads will // start at this time; the call will be ignored for ad rules. adsManager.start(); // window.addEventListener('resize', function (event) { // if (adsManager) { // setupDimensions(); // adsManager.resize(outstreamWidth, outstreamHeight, google.ima.ViewMode.NORMAL); // } // }); } catch (adError) { // An error may be thrown if there was a problem with the VAST response. // videoContent.play(); } } /** * Handles the ad manager loading and sets ad event listeners. * @param { !google.ima.AdsManagerLoadedEvent } adsManagerLoadedEvent */ function onAdsManagerLoaded(adsManagerLoadedEvent) { // Get the ads manager. const adsRenderingSettings = new google.ima.AdsRenderingSettings(); adsRenderingSettings.restoreCustomPlaybackStateOnAdBreakComplete = true; adsRenderingSettings.loadVideoTimeout = 12000; // videoContent should be set to the content video element. adsManager = adsManagerLoadedEvent.getAdsManager(videoContent, adsRenderingSettings); // Add listeners to the required events. adsManager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, onAdError); adsManager.addEventListener( google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, onContentPauseRequested); adsManager.addEventListener( google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED, onContentResumeRequested); adsManager.addEventListener( google.ima.AdEvent.Type.ALL_ADS_COMPLETED, onAdEvent); // Listen to any additional events, if necessary. adsManager.addEventListener(google.ima.AdEvent.Type.LOADED, onAdEvent); adsManager.addEventListener(google.ima.AdEvent.Type.STARTED, onAdEvent); adsManager.addEventListener(google.ima.AdEvent.Type.COMPLETE, onAdEvent); playAds(); } /** * Handles actions taken in response to ad events. * @param { !google.ima.AdEvent } adEvent */ function onAdEvent(adEvent) { // Retrieve the ad from the event. Some events (for example, // ALL_ADS_COMPLETED) don't have ad object associated. const ad = adEvent.getAd(); console.log('Outstream event: ' + adEvent.type); switch (adEvent.type) { case google.ima.AdEvent.Type.LOADED: // This is the first event sent for an ad - it is possible to // determine whether the ad is a video ad or an overlay. if (!ad.isLinear()) { // Position AdDisplayContainer correctly for overlay. // Use ad.width and ad.height. videoContent.play(); } outstreamDocument.getElementById('adContainer').style.width = '100%'; outstreamDocument.getElementById('adContainer').style.maxWidth = '640px'; outstreamDocument.getElementById('adContainer').style.height = '360px'; break; case google.ima.AdEvent.Type.STARTED: window.addEventListener('scroll', onActiveView); // This event indicates the ad has started - the video player // can adjust the UI, for example display a pause button and // remaining time. if (ad.isLinear()) { // For a linear ad, a timer can be started to poll for // the remaining time. intervalTimer = setInterval( function () { // Example: const remainingTime = adsManager.getRemainingTime(); // adsManager.pause(); }, 300); // every 300ms } outstreamDocument.getElementById('adMuteBtn').style.display = 'block'; break; case google.ima.AdEvent.Type.ALL_ADS_COMPLETED: if (ad.isLinear()) { clearInterval(intervalTimer); } if (outstreamLastError === 303) { if (isBanner) { renderBanner(); } else { replayScreen.style.display = 'flex'; } } break; case google.ima.AdEvent.Type.COMPLETE: // This event indicates the ad has finished - the video player // can perform appropriate UI actions, such as removing the timer for // remaining time detection. if (ad.isLinear()) { clearInterval(intervalTimer); } if (isBanner) { renderBanner(); } else { replayScreen.style.display = 'flex'; } break; } } /** * Handles ad errors. * @param { !google.ima.AdErrorEvent } adErrorEvent */ function onAdError(adErrorEvent) { // Handle the error logging. console.log(adErrorEvent.getError()); outstreamLastError = adErrorEvent.getError().getErrorCode(); if (!loadNext()) { renderBanner(); } } function renderBanner() { if (isBanner) { console.log('Outstream: Render Banner'); iinfoOutstreamPosition.innerHTML = ""; iinfoOutstreamPosition.style.height = "330px"; iinfoOutstreamPosition.appendChild(bannerDiv); } else { console.log('Outstream: Banner is not set'); } } function loadNext() { iinfoVastUrlIndex++; if (iinfoVastUrlIndex < iinfoVastUrls.length) { iinfoOutstreamPosition.remove(); outstreamInit(); } else { return false; } adVolume = 1; return true; } /** * Pauses video content and sets up ad UI. */ function onContentPauseRequested() { videoContent.pause(); // This function is where you should setup UI for showing ads (for example, // display ad timer countdown, disable seeking and more.) // setupUIForAds(); } /** * Resumes video content and removes ad UI. */ function onContentResumeRequested() { videoContent.play(); // This function is where you should ensure that your UI is ready // to play content. It is the responsibility of the Publisher to // implement this function when necessary. // setupUIForContent(); } function onActiveView() { if (outstreamContainer) { const containerOffset = outstreamContainer.getBoundingClientRect(); const windowHeight = window.innerHeight; if (containerOffset.top < windowHeight/1 && containerOffset.bottom > 0.0) { if (outstreamPaused) { adsManager.resume(); outstreamPaused = false; } return true; } else { if (!outstreamPaused) { adsManager.pause(); outstreamPaused = true; } } } return false; } let outstreamInitInterval; if (typeof cpexPackage !== "undefined") { outstreamInitInterval = setInterval(tryToInitializeOutstream, 100); } else { const wrapper = getWrapper(); if (wrapper) { let outstreamInitialized = false; window.addEventListener('scroll', () => { if (!outstreamInitialized) { const containerOffset = wrapper.getBoundingClientRect(); const windowHeight = window.innerHeight; if (containerOffset.top < windowHeight / 1 && containerOffset.bottom > 0.0) { outstreamInit(); outstreamInitialized = true; } } }); } } function tryToInitializeOutstream() { const wrapper = getWrapper(); if (wrapper) { const containerOffset = wrapper.getBoundingClientRect(); const windowHeight = window.innerHeight; if (containerOffset.top < windowHeight / 1 && containerOffset.bottom > 0.0) { if (cpexPackage.adserver.displayed) { clearInterval(outstreamInitInterval); outstreamInit(); } } } else { clearInterval(outstreamInitInterval); } } }
OSZAR »