LoginSignup
8
12

More than 5 years have passed since last update.

Let's EncryptのDNS認証を試してみる

Last updated at Posted at 2017-07-18

TL;DR

HTTP方式とほぼ同じでいけます。
違いは認証用Tokenが反映されるまでにインターバルがある事くらいでした。
自分でDNSを引いて認証用Tokenが反映されているのを確認した後、VerifyすればOKです。
作ったものは https://github.com/nak1114/letsencrypt-dns01/ に置いておきます。

きっかけ

Let's Encryptが18年1月にACME-v2になり、Wildcard SSL証明書を発行可能になるとアナウンスがあったがきっかけです。
intra.example.comのようなあからさまにイントラ用のDomainでも今までは外に向けて公開していました。Wildcardに出来るのならこういうのを隠蔽できるだろうと、ACME-v1だけど一足先にDNS認証に切り替えてみようと思いました。
権威サーバの保守の方が面倒そうだけど、とりあえず問題が出るまでは運用してみようかなと。

やったこと

ACMEクライアント

欲しいのはACME-v2での自動化ですので、まずは現在の数多あるACMEクライアントからv2対応してくれそうなのを探す必要があります。
かるくググってみるとこれ( https://www.bountysource.com/issues/46237938-acme-v2 )が見つかったので、やる気はあるとみてこれに決定します。

コード

基本的には流れはHTTPと同じで以下の通りです。
1. レジストして
2. 認証用トークンを貰って
3. DNSサーバにトークンを反映して
4. Verifyして
5. 証明書発行する

レジスト

レジストした秘密鍵は保存して再利用します。秘密鍵がない時は新しく秘密鍵を作ってレジストする。
HTTPの時と同じです。

Core#set_client
  def initialize(zone={})
    @zone = normalization(zone)
    @client = set_client()
    @log = Logger.new(@zone[:logfile], 5, 1024000)
  end

  def set_client
    filename=@zone[:authkey]
    if File.exist?(filename)
      key = OpenSSL::PKey::RSA.new(File.read(filename))
      return Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
    end
    key = OpenSSL::PKey::RSA.new(4096)
    cli = Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
    registration = cli.register(contact: "mailto:#{@zone[:mail]}")
    registration.agree_terms
    FileUtils.mkdir_p(File.dirname(filename))
    File.write(filename, key.to_pem)
    File.chmod(0400, filename)
    cli
  end

認証用トークン取得

トークン取得ですがauthorization.dns01とする事でDNS認証用のトークンになるようです。

Core#authorize
        #get challenge token
        authorization = @client.authorize(domain: domain)
        challenge = authorization.dns01
        token =%(#{challenge.record_name}.#{domain}. IN #{challenge.record_type} "#{challenge.record_content}"\n)

DNSサーバにトークンを反映

お金がないのでRoute53だの何だのは使いません。
zonefileにしこしこ書いてdnsサーバをreloadします。
zonefileはSOAのserial値をインクリメントしないとダメなので、そこら辺もここでやります。
トークンはコメントtoken areaを見つけて更新します。

BIND9#update_zonefile
    def update_zonefile(token="")
      filename=@core.zone[:zonefile]
      content=File.read(filename)
      #update serial
      content.sub!(/^\s+(\d+)\s*\;\s*serial$/i) do |m|
          @serial=[$1.to_i+1,@serial].max
          $&.sub(/\d+/,@serial.to_s)
      end
      #delete old token
      content.sub!(/^\; token area$.*\z/im) do |m|
          "; token area\n"
      end
      #add new token
      content+=token

      #write zonefile
      File.write(filename,content)

      #reload name server
      @core.zone[:command].each{|c| system(c)} if @core.zone[:command]
    end

対応するzonefileがこれ。シリアル値の横にコメントでserialと書いて、zonefileの末尾にコメントでtoken areaと書いてあれば準備OKです。

example.com.zone
$TTL    86400
@               IN SOA  ns.example.com.       root.example.com. (
                  2017071700      ; Serial
                  3H              ; refresh
                  15M             ; retry
                  2W              ; expiry
                  1D )            ; minimum

@               IN NS        ns.example.com.
@               IN NS        ns.example.net.
@               A            192.0.2.0

ns              A            192.0.2.1
www             A            192.0.2.2
bar.baz         A            192.0.2.3

; token area

Verifyして

はまりポイントでした。HTTP認証のノリでトークンを書き込んだ直後にchallenge.request_verificationすると認証が通りません。この場合challenge.verify_statusinvalidを返してきます。
原因はLetsEncrypt側がトークンを読めないからなのですが、
waitとしてhostコマンドなどでDNSを引いて確認すると今度は浸透するまで時間が掛かりすぎます。
色々試してセカンダリDNSサーバ(SlaveDNSサーバ?)から確認出来ればLetsEncrypt側もトークンを読めるようなので、今回はセカンダリDNSサーバからトークンを読み取って一致してからVerifyしています。

Core#authorize
        #check DNS record
        dns=Resolv::DNS.new(:nameserver => @zone[:nameserver])
        cname="#{challenge.record_name}.#{domain}"
        ctxt=challenge.record_content
        @log.info  "token #{sum},#{ctxt}"
        begin
          sleep 10
          ret=dns.getresources(cname, Resolv::DNS::Resource::IN::TXT)
        end until ret.size > 0 && ctxt==ret[0].data

        #verify token
        challenge.request_verification
        sleep 5 while challenge.verify_status == "pending"
        sum+=1 if challenge.verify_status=="valid"
        # sum+=1 if t.challenge.verify_status=="invalid"
        @log.info "verified! #{sum},#{domain}"

5. 証明書発行する

ここは特に問題なく証明書が発行されました。

Core#update_cert
  # update_cert updates/create some certification files under serial dir.
  def update_cert(serial)
    rcsr={names: @zone[:domain]}
    rcsr[:common_name]=@zone[:domain][0] if @zone[:domain].size > 1
    csr = Acme::Client::CertificateRequest.new(rcsr)
    certificate = @client.new_certificate(csr)

    cdir=@zone[:certdir]+"/current"
    rdir=@zone[:certdir]+"/#{serial}/"

    FileUtils.mkdir_p(rdir)

    File.write(rdir+@zone[:certname][:privkey  ], certificate.request.private_key.to_pem)
    File.write(rdir+@zone[:certname][:cert     ], certificate.to_pem)
    File.write(rdir+@zone[:certname][:chain    ], certificate.chain_to_pem)
    File.write(rdir+@zone[:certname][:fullchain], certificate.fullchain_to_pem)

    FileUtils.rm(cdir,{force: true})
    FileUtils.ln_s(rdir.chop,cdir,{force: true})
  end

コード全景

簡単なrspec込みでgithub に置きました。
ちなみにこれ以上gemとしての体裁を整えるつもりはないです。

core.rb
require 'resolv'
require 'logger'
require 'acme-client'
require 'fileutils'
require 'date'
# require 'mail'
module Letsencrypt
  module Dns01
    # Your code goes here...
  end
end

class Letsencrypt::Dns01::BIND9

    def initialize(cfg)
      @serial = Date.today.strftime('%Y%m%d00').to_i
      @core=Letsencrypt::Dns01::Core.new(cfg)
    end

    def update()
      #add token and update serial
      @core.authorize do |v|
        update_zonefile(v)
      end

      @serial
    end

    def update_zonefile(token="")
      filename=@core.zone[:zonefile]
      content=File.read(filename)
      #update serial
      content.sub!(/^\s+(\d+)\s*\;\s*serial$/i) do |m|
          @serial=[$1.to_i+1,@serial].max
          $&.sub(/\d+/,@serial.to_s)
      end
      #delete old token
      content.sub!(/^\; token area$.*\z/im) do |m|
          "; token area\n"
      end
      #add new token
      content+=token

      #write zonefile
      File.write(filename,content)

      #reload name server
      @core.zone[:command].each{|c| system(c)} if @core.zone[:command]
    end
end


class Letsencrypt::Dns01::Core
  Token = Struct.new(:domain, :challenge)
  attr_reader :zone, :log

  # initialize login to ACME server.
  # And it creates an authrization key file, if necessary.
  def initialize(zone={})
    @zone = normalization(zone)
    @client = set_client()
    @log = Logger.new(@zone[:logfile], 5, 1024000)
  end

  # authorize gets the authrization/verification token from the ACME server according to the domain list.And update certification after verify all domain.
  # return unknown.
  def authorize
    if expire?
      @log.info "start update"
      ret=@zone[:domain].reduce(0) do |sum,domain|
        @log.info "authorize #{sum},#{domain}"

        #get challenge token
        authorization = @client.authorize(domain: domain)
        challenge = authorization.dns01

        #update DNS record
        yield(%(#{challenge.record_name}.#{domain}. IN #{challenge.record_type} "#{challenge.record_content}"\n))

        #check DNS record
        dns=Resolv::DNS.new(:nameserver => @zone[:nameserver])
        cname="#{challenge.record_name}.#{domain}"
        ctxt=challenge.record_content
        @log.info  "token #{sum},#{ctxt}"
        begin
          sleep 10
          ret=dns.getresources(cname, Resolv::DNS::Resource::IN::TXT)
        end until ret.size > 0 && ctxt==ret[0].data

        #verify token
        challenge.request_verification
        sleep 5 while challenge.verify_status == "pending"
        sum+=1 if challenge.verify_status=="valid"
        # sum+=1 if t.challenge.verify_status=="invalid"
        @log.info "verified! #{sum},#{domain}"
        sum
      end

      #delete DNS record
      yield("")

      #cartificate
      if ret==@zone[:domain].size
        serial=Time.now.strftime("%Y%m%d%H%M%S")
        @log.info "update_cert #{serial}"
        update_cert(serial)
      end

      @log.info "complete update."
    else
      @log.info "skip update"
    end
    @log.close
  end

  # private

  def normalization(zone)
    zone[:endpoint]||="https://acme-staging.api.letsencrypt.org"
    zone[:mail]||="root@example.com"

    zone[:margin_days ]||=30
    zone[:warning_days]||=7

    domains =zone[:domains ]||zone[:domain ]
    zone[:domain ]=domains
    zone[:domain ]=[domains ] unless domains.instance_of?(Array)
    commands=zone[:commands]||zone[:command]||[]
    zone[:command]=commands
    zone[:command]=[commands] unless commands.instance_of?(Array)

    zone[:certdir]||=File.expand_path(File.dirname($0))
    zone[:certname]||={}
    zone[:certname][:privkey  ]||="privkey.pem"
    zone[:certname][:cert     ]||="cert.pem"
    zone[:certname][:chain    ]||="chain.pem"
    zone[:certname][:fullchain]||="fullchain.pem"

    zone[:logfile]||=STDOUT

    zone
  end

  def set_client
    filename=@zone[:authkey]
    if File.exist?(filename)
      key = OpenSSL::PKey::RSA.new(File.read(filename))
      return Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
    end
    key = OpenSSL::PKey::RSA.new(4096)
    cli = Acme::Client.new(private_key: key, endpoint: @zone[:endpoint])
    registration = cli.register(contact: "mailto:#{@zone[:mail]}")
    registration.agree_terms
    FileUtils.mkdir_p(File.dirname(filename))
    File.write(filename, key.to_pem)
    File.chmod(0400, filename)
    cli
  end

  # update_cert updates/create some certification files under serial dir.
  def update_cert(serial)
    rcsr={names: @zone[:domain]}
    rcsr[:common_name]=@zone[:domain][0] if @zone[:domain].size > 1
    csr = Acme::Client::CertificateRequest.new(rcsr)
    certificate = @client.new_certificate(csr)

    cdir=@zone[:certdir]+"/current"
    rdir=@zone[:certdir]+"/#{serial}/"

    FileUtils.mkdir_p(rdir)

    File.write(rdir+@zone[:certname][:privkey  ], certificate.request.private_key.to_pem)
    File.write(rdir+@zone[:certname][:cert     ], certificate.to_pem)
    File.write(rdir+@zone[:certname][:chain    ], certificate.chain_to_pem)
    File.write(rdir+@zone[:certname][:fullchain], certificate.fullchain_to_pem)

    FileUtils.rm(cdir,{force: true})
    FileUtils.ln_s(rdir.chop,cdir,{force: true})
  end

  # expire? check rest days by the current public key .
  # return true if no file or file is expired.
  def expire?
    fname=@zone[:certdir]+"/current/"+@zone[:certname][:cert]
    return true unless File.exist?(fname)

    cert = OpenSSL::X509::Certificate.new(File.read(fname))
    rest = cert.not_after - Time.now
    return false if rest > (@zone[:margin_days]*24*60*60)
    return true
  end
end

if $0 == __FILE__
  Letsencrypt::Dns01::BIND9.new({
    name: 'example.com',
    zonefile: 'spec/data/example.com.zone',
    nameserver: [ '203.0.113.0' ],
    domains: [
      'example.com',
      'www.example.com' 
    ],
    authkey: 'spec/data/key/example.com.pem',
    certdir: 'spec/data/example.com',
    logfile: 'spec/data/letsencrypt_example.com.log',
    commands: [
       #'service nsd restart',
       #'nginx -s reload',
    ],
    endpoint: 'https://acme-staging.api.letsencrypt.org',
    mail: 'hogehoge@hotmail.com',
  }).update()
end

やってみて

HTTP認証からDNS認証に切り替えただけで最初の懸念だったintra.example.comのAレコードが取り除けてしまった。もうこれでよいのではないだろうか。いや、SANだと証明書の拡張領域に思いっきりintra.example.comって出てくるので駄目だ。

でもWildcard SSLも魅力的なのでLetsEncryptが対応したら追従する予定です。

参考文献

RubyでLet's Encryptのスクリプト http://qiita.com/sawanoboly/items/f23e73c613e9454076b8

8
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
12