aboutsummaryrefslogtreecommitdiff
path: root/plugin/dnssec
diff options
context:
space:
mode:
Diffstat (limited to 'plugin/dnssec')
-rw-r--r--plugin/dnssec/README.md88
-rw-r--r--plugin/dnssec/black_lies.go24
-rw-r--r--plugin/dnssec/black_lies_test.go49
-rw-r--r--plugin/dnssec/cache.go22
-rw-r--r--plugin/dnssec/cache_test.go34
-rw-r--r--plugin/dnssec/dnskey.go72
-rw-r--r--plugin/dnssec/dnssec.go135
-rw-r--r--plugin/dnssec/dnssec_test.go219
-rw-r--r--plugin/dnssec/handler.go82
-rw-r--r--plugin/dnssec/handler_test.go155
-rw-r--r--plugin/dnssec/responsewriter.go49
-rw-r--r--plugin/dnssec/rrsig.go53
-rw-r--r--plugin/dnssec/setup.go128
-rw-r--r--plugin/dnssec/setup_test.go120
14 files changed, 1230 insertions, 0 deletions
diff --git a/plugin/dnssec/README.md b/plugin/dnssec/README.md
new file mode 100644
index 000000000..e087f6c9a
--- /dev/null
+++ b/plugin/dnssec/README.md
@@ -0,0 +1,88 @@
+# dnssec
+
+*dnssec* enables on-the-fly DNSSEC signing of served data.
+
+## Syntax
+
+~~~
+dnssec [ZONES... ] {
+ key file KEY...
+ cache_capacity CAPACITY
+}
+~~~
+
+The specified key is used for all signing operations. The DNSSEC signing will treat this key a
+CSK (common signing key), forgoing the ZSK/KSK split. All signing operations are done online.
+Authenticated denial of existence is implemented with NSEC black lies. Using ECDSA as an algorithm
+is preferred as this leads to smaller signatures (compared to RSA). NSEC3 is *not* supported.
+
+If multiple *dnssec* plugins are specified in the same zone, the last one specified will be
+used ( see [bugs](#bugs) ).
+
+* `ZONES` zones that should be signed. If empty, the zones from the configuration block
+ are used.
+
+* `key file` indicates that key file(s) should be read from disk. When multiple keys are specified, RRsets
+ will be signed with all keys. Generating a key can be done with `dnssec-keygen`: `dnssec-keygen -a
+ ECDSAP256SHA256 <zonename>`. A key created for zone *A* can be safely used for zone *B*. The name of the
+ key file can be specified as one of the following formats
+
+ * basename of the generated key `Kexample.org+013+45330`
+
+ * generated public key `Kexample.org+013+45330.key`
+
+ * generated private key `Kexample.org+013+45330.private`
+
+* `cache_capacity` indicates the capacity of the cache. The dnssec plugin uses a cache to store
+ RRSIGs. The default capacity is 10000.
+
+## Metrics
+
+If monitoring is enabled (via the *prometheus* directive) then the following metrics are exported:
+
+* coredns_dnssec_cache_size{type} - total elements in the cache, type is "signature".
+* coredns_dnssec_cache_capacity{type} - total capacity of the cache, type is "signature".
+* coredns_dnssec_cache_hits_total - Counter of cache hits.
+* coredns_dnssec_cache_misses_total - Counter of cache misses.
+
+## Examples
+
+Sign responses for `example.org` with the key "Kexample.org.+013+45330.key".
+
+~~~
+example.org:53 {
+ dnssec {
+ key file /etc/coredns/Kexample.org.+013+45330
+ }
+ whoami
+}
+~~~
+
+Sign responses for a kubernetes zone with the key "Kcluster.local+013+45129.key".
+
+~~~
+cluster.local:53 {
+ kubernetes cluster.local
+ dnssec cluster.local {
+ key file /etc/coredns/Kcluster.local+013+45129
+ }
+}
+~~~
+
+## Bugs
+
+Multiple *dnssec* plugins inside one server stanza will silently overwrite earlier ones, here
+`example.local` will overwrite the one for `cluster.local`.
+
+~~~
+.:53 {
+ kubernetes cluster.local
+ dnssec cluster.local {
+ key file /etc/coredns/cluster.local
+ }
+ dnssec example.local {
+ key file /etc/coredns/example.local
+ }
+ whoami
+}
+~~~
diff --git a/plugin/dnssec/black_lies.go b/plugin/dnssec/black_lies.go
new file mode 100644
index 000000000..527b2fc3e
--- /dev/null
+++ b/plugin/dnssec/black_lies.go
@@ -0,0 +1,24 @@
+package dnssec
+
+import "github.com/miekg/dns"
+
+// nsec returns an NSEC useful for NXDOMAIN respsones.
+// See https://tools.ietf.org/html/draft-valsorda-dnsop-black-lies-00
+// For example, a request for the non-existing name a.example.com would
+// cause the following NSEC record to be generated:
+// a.example.com. 3600 IN NSEC \000.a.example.com. ( RRSIG NSEC )
+// This inturn makes every NXDOMAIN answer a NODATA one, don't forget to flip
+// the header rcode to NOERROR.
+func (d Dnssec) nsec(name, zone string, ttl, incep, expir uint32) ([]dns.RR, error) {
+ nsec := &dns.NSEC{}
+ nsec.Hdr = dns.RR_Header{Name: name, Ttl: ttl, Class: dns.ClassINET, Rrtype: dns.TypeNSEC}
+ nsec.NextDomain = "\\000." + name
+ nsec.TypeBitMap = []uint16{dns.TypeRRSIG, dns.TypeNSEC}
+
+ sigs, err := d.sign([]dns.RR{nsec}, zone, ttl, incep, expir)
+ if err != nil {
+ return nil, err
+ }
+
+ return append(sigs, nsec), nil
+}
diff --git a/plugin/dnssec/black_lies_test.go b/plugin/dnssec/black_lies_test.go
new file mode 100644
index 000000000..80c2ce484
--- /dev/null
+++ b/plugin/dnssec/black_lies_test.go
@@ -0,0 +1,49 @@
+package dnssec
+
+import (
+ "testing"
+ "time"
+
+ "github.com/coredns/coredns/plugin/test"
+ "github.com/coredns/coredns/request"
+
+ "github.com/miekg/dns"
+)
+
+func TestZoneSigningBlackLies(t *testing.T) {
+ d, rm1, rm2 := newDnssec(t, []string{"miek.nl."})
+ defer rm1()
+ defer rm2()
+
+ m := testNxdomainMsg()
+ state := request.Request{Req: m}
+ m = d.Sign(state, "miek.nl.", time.Now().UTC())
+ if !section(m.Ns, 2) {
+ t.Errorf("authority section should have 2 sig")
+ }
+ var nsec *dns.NSEC
+ for _, r := range m.Ns {
+ if r.Header().Rrtype == dns.TypeNSEC {
+ nsec = r.(*dns.NSEC)
+ }
+ }
+ if m.Rcode != dns.RcodeSuccess {
+ t.Errorf("expected rcode %d, got %d", dns.RcodeSuccess, m.Rcode)
+ }
+ if nsec == nil {
+ t.Fatalf("expected NSEC, got none")
+ }
+ if nsec.Hdr.Name != "ww.miek.nl." {
+ t.Errorf("expected %s, got %s", "ww.miek.nl.", nsec.Hdr.Name)
+ }
+ if nsec.NextDomain != "\\000.ww.miek.nl." {
+ t.Errorf("expected %s, got %s", "\\000.ww.miek.nl.", nsec.NextDomain)
+ }
+}
+
+func testNxdomainMsg() *dns.Msg {
+ return &dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeNameError},
+ Question: []dns.Question{{Name: "ww.miek.nl.", Qclass: dns.ClassINET, Qtype: dns.TypeTXT}},
+ Ns: []dns.RR{test.SOA("miek.nl. 1800 IN SOA linode.atoom.net. miek.miek.nl. 1461471181 14400 3600 604800 14400")},
+ }
+}
diff --git a/plugin/dnssec/cache.go b/plugin/dnssec/cache.go
new file mode 100644
index 000000000..ea95b73b4
--- /dev/null
+++ b/plugin/dnssec/cache.go
@@ -0,0 +1,22 @@
+package dnssec
+
+import (
+ "hash/fnv"
+
+ "github.com/miekg/dns"
+)
+
+// hash serializes the RRset and return a signature cache key.
+func hash(rrs []dns.RR) uint32 {
+ h := fnv.New32()
+ buf := make([]byte, 256)
+ for _, r := range rrs {
+ off, err := dns.PackRR(r, buf, 0, nil, false)
+ if err == nil {
+ h.Write(buf[:off])
+ }
+ }
+
+ i := h.Sum32()
+ return i
+}
diff --git a/plugin/dnssec/cache_test.go b/plugin/dnssec/cache_test.go
new file mode 100644
index 000000000..b978df244
--- /dev/null
+++ b/plugin/dnssec/cache_test.go
@@ -0,0 +1,34 @@
+package dnssec
+
+import (
+ "testing"
+ "time"
+
+ "github.com/coredns/coredns/plugin/pkg/cache"
+ "github.com/coredns/coredns/plugin/test"
+ "github.com/coredns/coredns/request"
+)
+
+func TestCacheSet(t *testing.T) {
+ fPriv, rmPriv, _ := test.TempFile(".", privKey)
+ fPub, rmPub, _ := test.TempFile(".", pubKey)
+ defer rmPriv()
+ defer rmPub()
+
+ dnskey, err := ParseKeyFile(fPub, fPriv)
+ if err != nil {
+ t.Fatalf("failed to parse key: %v\n", err)
+ }
+
+ c := cache.New(defaultCap)
+ m := testMsg()
+ state := request.Request{Req: m}
+ k := hash(m.Answer) // calculate *before* we add the sig
+ d := New([]string{"miek.nl."}, []*DNSKEY{dnskey}, nil, c)
+ d.Sign(state, "miek.nl.", time.Now().UTC())
+
+ _, ok := d.get(k)
+ if !ok {
+ t.Errorf("signature was not added to the cache")
+ }
+}
diff --git a/plugin/dnssec/dnskey.go b/plugin/dnssec/dnskey.go
new file mode 100644
index 000000000..ce787ab54
--- /dev/null
+++ b/plugin/dnssec/dnskey.go
@@ -0,0 +1,72 @@
+package dnssec
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "errors"
+ "os"
+ "time"
+
+ "github.com/coredns/coredns/request"
+
+ "github.com/miekg/dns"
+)
+
+// DNSKEY holds a DNSSEC public and private key used for on-the-fly signing.
+type DNSKEY struct {
+ K *dns.DNSKEY
+ s crypto.Signer
+ keytag uint16
+}
+
+// ParseKeyFile read a DNSSEC keyfile as generated by dnssec-keygen or other
+// utilities. It adds ".key" for the public key and ".private" for the private key.
+func ParseKeyFile(pubFile, privFile string) (*DNSKEY, error) {
+ f, e := os.Open(pubFile)
+ if e != nil {
+ return nil, e
+ }
+ k, e := dns.ReadRR(f, pubFile)
+ if e != nil {
+ return nil, e
+ }
+
+ f, e = os.Open(privFile)
+ if e != nil {
+ return nil, e
+ }
+ p, e := k.(*dns.DNSKEY).ReadPrivateKey(f, privFile)
+ if e != nil {
+ return nil, e
+ }
+
+ if v, ok := p.(*rsa.PrivateKey); ok {
+ return &DNSKEY{k.(*dns.DNSKEY), v, k.(*dns.DNSKEY).KeyTag()}, nil
+ }
+ if v, ok := p.(*ecdsa.PrivateKey); ok {
+ return &DNSKEY{k.(*dns.DNSKEY), v, k.(*dns.DNSKEY).KeyTag()}, nil
+ }
+ return &DNSKEY{k.(*dns.DNSKEY), nil, 0}, errors.New("no known? private key found")
+}
+
+// getDNSKEY returns the correct DNSKEY to the client. Signatures are added when do is true.
+func (d Dnssec) getDNSKEY(state request.Request, zone string, do bool) *dns.Msg {
+ keys := make([]dns.RR, len(d.keys))
+ for i, k := range d.keys {
+ keys[i] = dns.Copy(k.K)
+ keys[i].Header().Name = zone
+ }
+ m := new(dns.Msg)
+ m.SetReply(state.Req)
+ m.Answer = keys
+ if !do {
+ return m
+ }
+
+ incep, expir := incepExpir(time.Now().UTC())
+ if sigs, err := d.sign(keys, zone, 3600, incep, expir); err == nil {
+ m.Answer = append(m.Answer, sigs...)
+ }
+ return m
+}
diff --git a/plugin/dnssec/dnssec.go b/plugin/dnssec/dnssec.go
new file mode 100644
index 000000000..84de05c86
--- /dev/null
+++ b/plugin/dnssec/dnssec.go
@@ -0,0 +1,135 @@
+// Package dnssec implements a plugin that signs responses on-the-fly using
+// NSEC black lies.
+package dnssec
+
+import (
+ "time"
+
+ "github.com/coredns/coredns/plugin"
+ "github.com/coredns/coredns/plugin/pkg/cache"
+ "github.com/coredns/coredns/plugin/pkg/response"
+ "github.com/coredns/coredns/plugin/pkg/singleflight"
+ "github.com/coredns/coredns/request"
+
+ "github.com/miekg/dns"
+)
+
+// Dnssec signs the reply on-the-fly.
+type Dnssec struct {
+ Next plugin.Handler
+
+ zones []string
+ keys []*DNSKEY
+ inflight *singleflight.Group
+ cache *cache.Cache
+}
+
+// New returns a new Dnssec.
+func New(zones []string, keys []*DNSKEY, next plugin.Handler, c *cache.Cache) Dnssec {
+ return Dnssec{Next: next,
+ zones: zones,
+ keys: keys,
+ cache: c,
+ inflight: new(singleflight.Group),
+ }
+}
+
+// Sign signs the message in state. it takes care of negative or nodata responses. It
+// uses NSEC black lies for authenticated denial of existence. Signatures
+// creates will be cached for a short while. By default we sign for 8 days,
+// starting 3 hours ago.
+func (d Dnssec) Sign(state request.Request, zone string, now time.Time) *dns.Msg {
+ req := state.Req
+
+ mt, _ := response.Typify(req, time.Now().UTC()) // TODO(miek): need opt record here?
+ if mt == response.Delegation {
+ // TODO(miek): uh, signing DS record?!?!
+ return req
+ }
+
+ incep, expir := incepExpir(now)
+
+ if mt == response.NameError {
+ if req.Ns[0].Header().Rrtype != dns.TypeSOA || len(req.Ns) > 1 {
+ return req
+ }
+
+ ttl := req.Ns[0].Header().Ttl
+
+ if sigs, err := d.sign(req.Ns, zone, ttl, incep, expir); err == nil {
+ req.Ns = append(req.Ns, sigs...)
+ }
+ if sigs, err := d.nsec(state.Name(), zone, ttl, incep, expir); err == nil {
+ req.Ns = append(req.Ns, sigs...)
+ }
+ if len(req.Ns) > 1 { // actually added nsec and sigs, reset the rcode
+ req.Rcode = dns.RcodeSuccess
+ }
+ return req
+ }
+
+ for _, r := range rrSets(req.Answer) {
+ ttl := r[0].Header().Ttl
+ if sigs, err := d.sign(r, zone, ttl, incep, expir); err == nil {
+ req.Answer = append(req.Answer, sigs...)
+ }
+ }
+ for _, r := range rrSets(req.Ns) {
+ ttl := r[0].Header().Ttl
+ if sigs, err := d.sign(r, zone, ttl, incep, expir); err == nil {
+ req.Ns = append(req.Ns, sigs...)
+ }
+ }
+ for _, r := range rrSets(req.Extra) {
+ ttl := r[0].Header().Ttl
+ if sigs, err := d.sign(r, zone, ttl, incep, expir); err == nil {
+ req.Extra = append(sigs, req.Extra...) // prepend to leave OPT alone
+ }
+ }
+ return req
+}
+
+func (d Dnssec) sign(rrs []dns.RR, signerName string, ttl, incep, expir uint32) ([]dns.RR, error) {
+ k := hash(rrs)
+ sgs, ok := d.get(k)
+ if ok {
+ return sgs, nil
+ }
+
+ sigs, err := d.inflight.Do(k, func() (interface{}, error) {
+ sigs := make([]dns.RR, len(d.keys))
+ var e error
+ for i, k := range d.keys {
+ sig := k.newRRSIG(signerName, ttl, incep, expir)
+ e = sig.Sign(k.s, rrs)
+ sigs[i] = sig
+ }
+ d.set(k, sigs)
+ return sigs, e
+ })
+ return sigs.([]dns.RR), err
+}
+
+func (d Dnssec) set(key uint32, sigs []dns.RR) {
+ d.cache.Add(key, sigs)
+}
+
+func (d Dnssec) get(key uint32) ([]dns.RR, bool) {
+ if s, ok := d.cache.Get(key); ok {
+ cacheHits.Inc()
+ return s.([]dns.RR), true
+ }
+ cacheMisses.Inc()
+ return nil, false
+}
+
+func incepExpir(now time.Time) (uint32, uint32) {
+ incep := uint32(now.Add(-3 * time.Hour).Unix()) // -(2+1) hours, be sure to catch daylight saving time and such
+ expir := uint32(now.Add(eightDays).Unix()) // sign for 8 days
+ return incep, expir
+}
+
+const (
+ eightDays = 8 * 24 * time.Hour
+ defaultCap = 10000 // default capacity of the cache.
+)
diff --git a/plugin/dnssec/dnssec_test.go b/plugin/dnssec/dnssec_test.go
new file mode 100644
index 000000000..83ce70beb
--- /dev/null
+++ b/plugin/dnssec/dnssec_test.go
@@ -0,0 +1,219 @@
+package dnssec
+
+import (
+ "testing"
+ "time"
+
+ "github.com/coredns/coredns/plugin/pkg/cache"
+ "github.com/coredns/coredns/plugin/test"
+ "github.com/coredns/coredns/request"
+
+ "github.com/miekg/dns"
+)
+
+func TestZoneSigning(t *testing.T) {
+ d, rm1, rm2 := newDnssec(t, []string{"miek.nl."})
+ defer rm1()
+ defer rm2()
+
+ m := testMsg()
+ state := request.Request{Req: m}
+
+ m = d.Sign(state, "miek.nl.", time.Now().UTC())
+ if !section(m.Answer, 1) {
+ t.Errorf("answer section should have 1 sig")
+ }
+ if !section(m.Ns, 1) {
+ t.Errorf("authority section should have 1 sig")
+ }
+}
+
+func TestZoneSigningDouble(t *testing.T) {
+ d, rm1, rm2 := newDnssec(t, []string{"miek.nl."})
+ defer rm1()
+ defer rm2()
+
+ fPriv1, rmPriv1, _ := test.TempFile(".", privKey1)
+ fPub1, rmPub1, _ := test.TempFile(".", pubKey1)
+ defer rmPriv1()
+ defer rmPub1()
+
+ key1, err := ParseKeyFile(fPub1, fPriv1)
+ if err != nil {
+ t.Fatalf("failed to parse key: %v\n", err)
+ }
+ d.keys = append(d.keys, key1)
+
+ m := testMsg()
+ state := request.Request{Req: m}
+ m = d.Sign(state, "miek.nl.", time.Now().UTC())
+ if !section(m.Answer, 2) {
+ t.Errorf("answer section should have 1 sig")
+ }
+ if !section(m.Ns, 2) {
+ t.Errorf("authority section should have 1 sig")
+ }
+}
+
+// TestSigningDifferentZone tests if a key for miek.nl and be used for example.org.
+func TestSigningDifferentZone(t *testing.T) {
+ fPriv, rmPriv, _ := test.TempFile(".", privKey)
+ fPub, rmPub, _ := test.TempFile(".", pubKey)
+ defer rmPriv()
+ defer rmPub()
+
+ key, err := ParseKeyFile(fPub, fPriv)
+ if err != nil {
+ t.Fatalf("failed to parse key: %v\n", err)
+ }
+
+ m := testMsgEx()
+ state := request.Request{Req: m}
+ c := cache.New(defaultCap)
+ d := New([]string{"example.org."}, []*DNSKEY{key}, nil, c)
+ m = d.Sign(state, "example.org.", time.Now().UTC())
+ if !section(m.Answer, 1) {
+ t.Errorf("answer section should have 1 sig")
+ t.Logf("%+v\n", m)
+ }
+ if !section(m.Ns, 1) {
+ t.Errorf("authority section should have 1 sig")
+ t.Logf("%+v\n", m)
+ }
+}
+
+func TestSigningCname(t *testing.T) {
+ d, rm1, rm2 := newDnssec(t, []string{"miek.nl."})
+ defer rm1()
+ defer rm2()
+
+ m := testMsgCname()
+ state := request.Request{Req: m}
+ m = d.Sign(state, "miek.nl.", time.Now().UTC())
+ if !section(m.Answer, 1) {
+ t.Errorf("answer section should have 1 sig")
+ }
+}
+
+func TestZoneSigningDelegation(t *testing.T) {
+ d, rm1, rm2 := newDnssec(t, []string{"miek.nl."})
+ defer rm1()
+ defer rm2()
+
+ m := testDelegationMsg()
+ state := request.Request{Req: m}
+ m = d.Sign(state, "miek.nl.", time.Now().UTC())
+ if !section(m.Ns, 0) {
+ t.Errorf("authority section should have 0 sig")
+ t.Logf("%v\n", m)
+ }
+ if !section(m.Extra, 0) {
+ t.Errorf("answer section should have 0 sig")
+ t.Logf("%v\n", m)
+ }
+}
+
+func TestSigningDname(t *testing.T) {
+ d, rm1, rm2 := newDnssec(t, []string{"miek.nl."})
+ defer rm1()
+ defer rm2()
+
+ m := testMsgDname()
+ state := request.Request{Req: m}
+ // We sign *everything* we see, also the synthesized CNAME.
+ m = d.Sign(state, "miek.nl.", time.Now().UTC())
+ if !section(m.Answer, 3) {
+ t.Errorf("answer section should have 3 sig")
+ }
+}
+
+func section(rss []dns.RR, nrSigs int) bool {
+ i := 0
+ for _, r := range rss {
+ if r.Header().Rrtype == dns.TypeRRSIG {
+ i++
+ }
+ }
+ return nrSigs == i
+}
+
+func testMsg() *dns.Msg {
+ // don't care about the message header
+ return &dns.Msg{
+ Answer: []dns.RR{test.MX("miek.nl. 1703 IN MX 1 aspmx.l.google.com.")},
+ Ns: []dns.RR{test.NS("miek.nl. 1703 IN NS omval.tednet.nl.")},
+ }
+}
+func testMsgEx() *dns.Msg {
+ return &dns.Msg{
+ Answer: []dns.RR{test.MX("example.org. 1703 IN MX 1 aspmx.l.google.com.")},
+ Ns: []dns.RR{test.NS("example.org. 1703 IN NS omval.tednet.nl.")},
+ }
+}
+
+func testMsgCname() *dns.Msg {
+ return &dns.Msg{
+ Answer: []dns.RR{test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl.")},
+ }
+}
+
+func testDelegationMsg() *dns.Msg {
+ return &dns.Msg{
+ Ns: []dns.RR{
+ test.NS("miek.nl. 3600 IN NS linode.atoom.net."),
+ test.NS("miek.nl. 3600 IN NS ns-ext.nlnetlabs.nl."),
+ test.NS("miek.nl. 3600 IN NS omval.tednet.nl."),
+ },
+ Extra: []dns.RR{
+ test.A("omval.tednet.nl. 3600 IN A 185.49.141.42"),
+ test.AAAA("omval.tednet.nl. 3600 IN AAAA 2a04:b900:0:100::42"),
+ },
+ }
+}
+
+func testMsgDname() *dns.Msg {
+ return &dns.Msg{
+ Answer: []dns.RR{
+ test.CNAME("a.dname.miek.nl. 1800 IN CNAME a.test.miek.nl."),
+ test.A("a.test.miek.nl. 1800 IN A 139.162.196.78"),
+ test.DNAME("dname.miek.nl. 1800 IN DNAME test.miek.nl."),
+ },
+ }
+}
+
+func newDnssec(t *testing.T, zones []string) (Dnssec, func(), func()) {
+ k, rm1, rm2 := newKey(t)
+ c := cache.New(defaultCap)
+ d := New(zones, []*DNSKEY{k}, nil, c)
+ return d, rm1, rm2
+}
+
+func newKey(t *testing.T) (*DNSKEY, func(), func()) {
+ fPriv, rmPriv, _ := test.TempFile(".", privKey)
+ fPub, rmPub, _ := test.TempFile(".", pubKey)
+
+ key, err := ParseKeyFile(fPub, fPriv)
+ if err != nil {
+ t.Fatalf("failed to parse key: %v\n", err)
+ }
+ return key, rmPriv, rmPub
+}
+
+const (
+ pubKey = `miek.nl. IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4BXPP3gwhetiOUMnGA+x09nqzgF5IY OyjWB7N3rXqQbnOSILhH1hnuyh7mmA==`
+ privKey = `Private-key-format: v1.3
+Algorithm: 13 (ECDSAP256SHA256)
+PrivateKey: /4BZk8AFvyW5hL3cOLSVxIp1RTqHSAEloWUxj86p3gs=
+Created: 20160423195532
+Publish: 20160423195532
+Activate: 20160423195532
+`
+ pubKey1 = `example.org. IN DNSKEY 257 3 13 tVRWNSGpHZbCi7Pr7OmbADVUO3MxJ0Lb8Lk3o/HBHqCxf5K/J50lFqRa 98lkdAIiFOVRy8LyMvjwmxZKwB5MNw==`
+ privKey1 = `Private-key-format: v1.3
+Algorithm: 13 (ECDSAP256SHA256)
+PrivateKey: i8j4OfDGT8CQt24SDwLz2hg9yx4qKOEOh1LvbAuSp1c=
+Created: 20160423211746
+Publish: 20160423211746
+Activate: 20160423211746
+`
+)
diff --git a/plugin/dnssec/handler.go b/plugin/dnssec/handler.go
new file mode 100644
index 000000000..6fa2dd042
--- /dev/null
+++ b/plugin/dnssec/handler.go
@@ -0,0 +1,82 @@
+package dnssec
+
+import (
+ "github.com/coredns/coredns/plugin"
+ "github.com/coredns/coredns/request"
+
+ "github.com/miekg/dns"
+ "github.com/prometheus/client_golang/prometheus"
+ "golang.org/x/net/context"
+)
+
+// ServeDNS implements the plugin.Handler interface.
+func (d Dnssec) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
+ state := request.Request{W: w, Req: r}
+
+ do := state.Do()
+ qname := state.Name()
+ qtype := state.QType()
+ zone := plugin.Zones(d.zones).Matches(qname)
+ if zone == "" {
+ return plugin.NextOrFailure(d.Name(), d.Next, ctx, w, r)
+ }
+
+ // Intercept queries for DNSKEY, but only if one of the zones matches the qname, otherwise we let
+ // the query through.
+ if qtype == dns.TypeDNSKEY {
+ for _, z := range d.zones {
+ if qname == z {
+ resp := d.getDNSKEY(state, z, do)
+ resp.Authoritative = true
+ state.SizeAndDo(resp)
+ w.WriteMsg(resp)
+ return dns.RcodeSuccess, nil
+ }
+ }
+ }
+
+ drr := &ResponseWriter{w, d}
+ return plugin.NextOrFailure(d.Name(), d.Next, ctx, drr, r)
+}
+
+var (
+ cacheSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Namespace: plugin.Namespace,
+ Subsystem: subsystem,
+ Name: "cache_size",
+ Help: "The number of elements in the dnssec cache.",
+ }, []string{"type"})
+
+ cacheCapacity = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Namespace: plugin.Namespace,
+ Subsystem: subsystem,
+ Name: "cache_capacity",
+ Help: "The dnssec cache's capacity.",
+ }, []string{"type"})
+
+ cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
+ Namespace: plugin.Namespace,
+ Subsystem: subsystem,
+ Name: "cache_hits_total",
+ Help: "The count of cache hits.",
+ })
+
+ cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
+ Namespace: plugin.Namespace,
+ Subsystem: subsystem,
+ Name: "cache_misses_total",
+ Help: "The count of cache misses.",
+ })
+)
+
+// Name implements the Handler interface.
+func (d Dnssec) Name() string { return "dnssec" }
+
+const subsystem = "dnssec"
+
+func init() {
+ prometheus.MustRegister(cacheSize)
+ prometheus.MustRegister(cacheCapacity)
+ prometheus.MustRegister(cacheHits)
+ prometheus.MustRegister(cacheMisses)
+}
diff --git a/plugin/dnssec/handler_test.go b/plugin/dnssec/handler_test.go
new file mode 100644
index 000000000..2202a9ffe
--- /dev/null
+++ b/plugin/dnssec/handler_test.go
@@ -0,0 +1,155 @@
+package dnssec
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/coredns/coredns/plugin/file"
+ "github.com/coredns/coredns/plugin/pkg/cache"
+ "github.com/coredns/coredns/plugin/pkg/dnsrecorder"
+ "github.com/coredns/coredns/plugin/test"
+
+ "github.com/miekg/dns"
+ "golang.org/x/net/context"
+)
+
+var dnssecTestCases = []test.Case{
+ {
+ Qname: "miek.nl.", Qtype: dns.TypeDNSKEY,
+ Answer: []dns.RR{
+ test.DNSKEY("miek.nl. 3600 IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4"),
+ },
+ },
+ {
+ Qname: "miek.nl.", Qtype: dns.TypeDNSKEY, Do: true,
+ Answer: []dns.RR{
+ test.DNSKEY("miek.nl. 3600 IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4"),
+ test.RRSIG("miek.nl. 3600 IN RRSIG DNSKEY 13 2 3600 20160503150844 20160425120844 18512 miek.nl. Iw/kNOyM"),
+ },
+ Extra: []dns.RR{test.OPT(4096, true)},
+ },
+}
+
+var dnsTestCases = []test.Case{
+ {
+ Qname: "miek.nl.", Qtype: dns.TypeDNSKEY,
+ Answer: []dns.RR{
+ test.DNSKEY("miek.nl. 3600 IN DNSKEY 257 3 13 0J8u0XJ9GNGFEBXuAmLu04taHG4"),
+ },
+ },
+ {
+ Qname: "miek.nl.", Qtype: dns.TypeMX,
+ Answer: []dns.RR{
+ test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."),
+ },
+ Ns: []dns.RR{
+ test.NS("miek.nl. 1800 IN NS linode.atoom.net."),
+ },
+ },
+ {
+ Qname: "miek.nl.", Qtype: dns.TypeMX, Do: true,
+ Answer: []dns.RR{
+ test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."),
+ test.RRSIG("miek.nl. 1800 IN RRSIG MX 13 2 3600 20160503192428 20160425162428 18512 miek.nl. 4nxuGKitXjPVA9zP1JIUvA09"),
+ },
+ Ns: []dns.RR{
+ test.NS("miek.nl. 1800 IN NS linode.atoom.net."),
+ test.RRSIG("miek.nl. 1800 IN RRSIG NS 13 2 3600 20161217114912 20161209084912 18512 miek.nl. ad9gA8VWgF1H8ze9/0Rk2Q=="),
+ },
+ Extra: []dns.RR{test.OPT(4096, true)},
+ },
+ {
+ Qname: "www.miek.nl.", Qtype: dns.TypeAAAA, Do: true,
+ Answer: []dns.RR{
+ test.AAAA("a.miek.nl. 1800 IN AAAA 2a01:7e00::f03c:91ff:fef1:6735"),
+ test.RRSIG("a.miek.nl. 1800 IN RRSIG AAAA 13 3 3600 20160503193047 20160425163047 18512 miek.nl. UAyMG+gcnoXW3"),
+ test.CNAME("www.miek.nl. 1800 IN CNAME a.miek.nl."),
+ test.RRSIG("www.miek.nl. 1800 IN RRSIG CNAME 13 3 3600 20160503193047 20160425163047 18512 miek.nl. E3qGZn"),
+ },
+ Ns: []dns.RR{
+ test.NS("miek.nl. 1800 IN NS linode.atoom.net."),
+ test.RRSIG("miek.nl. 1800 IN RRSIG NS 13 2 3600 20161217114912 20161209084912 18512 miek.nl. ad9gA8VWgF1H8ze9/0Rk2Q=="),
+ },
+ Extra: []dns.RR{test.OPT(4096, true)},
+ },
+ {
+ Qname: "www.example.org.", Qtype: dns.TypeAAAA, Do: true,
+ Rcode: dns.RcodeServerFailure,
+ // Extra: []dns.RR{test.OPT(4096, true)}, // test.ErrorHandler is a simple handler that does not do EDNS.
+ },
+}
+
+func TestLookupZone(t *testing.T) {
+ zone, err := file.Parse(strings.NewReader(dbMiekNL), "miek.nl.", "stdin", 0)
+ if err != nil {
+ return
+ }
+ fm := file.File{Next: test.ErrorHandler(), Zones: file.Zones{Z: map[string]*file.Zone{"miek.nl.": zone}, Names: []string{"miek.nl."}}}
+ dnskey, rm1, rm2 := newKey(t)
+ defer rm1()
+ defer rm2()
+ c := cache.New(defaultCap)
+ dh := New([]string{"miek.nl."}, []*DNSKEY{dnskey}, fm, c)
+ ctx := context.TODO()
+
+ for _, tc := range dnsTestCases {
+ m := tc.Msg()
+
+ rec := dnsrecorder.New(&test.ResponseWriter{})
+ _, err := dh.ServeDNS(ctx, rec, m)
+ if err != nil {
+ t.Errorf("expected no error, got %v\n", err)
+ return
+ }
+
+ resp := rec.Msg
+ test.SortAndCheck(t, resp, tc)
+ }
+}
+
+func TestLookupDNSKEY(t *testing.T) {
+ dnskey, rm1, rm2 := newKey(t)
+ defer rm1()
+ defer rm2()
+ c := cache.New(defaultCap)
+ dh := New([]string{"miek.nl."}, []*DNSKEY{dnskey}, test.ErrorHandler(), c)
+ ctx := context.TODO()
+
+ for _, tc := range dnssecTestCases {
+ m := tc.Msg()
+
+ rec := dnsrecorder.New(&test.ResponseWriter{})
+ _, err := dh.ServeDNS(ctx, rec, m)
+ if err != nil {
+ t.Errorf("expected no error, got %v\n", err)
+ return
+ }
+
+ resp := rec.Msg
+ if !resp.Authoritative {
+ t.Errorf("Authoritative Answer should be true, got false")
+ }
+
+ test.SortAndCheck(t, resp, tc)
+ }
+}
+
+const dbMiekNL = `
+$TTL 30M
+$ORIGIN miek.nl.
+@ IN SOA linode.atoom.net. miek.miek.nl. (
+ 1282630057 ; Serial
+ 4H ; Refresh
+ 1H ; Retry
+ 7D ; Expire
+ 4H ) ; Negative Cache TTL
+ IN NS linode.atoom.net.
+
+ IN MX 1 aspmx.l.google.com.
+
+ IN A 139.162.196.78
+ IN AAAA 2a01:7e00::f03c:91ff:fef1:6735
+
+a IN A 139.162.196.78
+ IN AAAA 2a01:7e00::f03c:91ff:fef1:6735
+www IN CNAME a`
diff --git a/plugin/dnssec/responsewriter.go b/plugin/dnssec/responsewriter.go
new file mode 100644
index 000000000..793cbcdd0
--- /dev/null
+++ b/plugin/dnssec/responsewriter.go
@@ -0,0 +1,49 @@
+package dnssec
+
+import (
+ "log"
+ "time"
+
+ "github.com/coredns/coredns/plugin"
+ "github.com/coredns/coredns/request"
+
+ "github.com/miekg/dns"
+)
+
+// ResponseWriter sign the response on the fly.
+type ResponseWriter struct {
+ dns.ResponseWriter
+ d Dnssec
+}
+
+// WriteMsg implements the dns.ResponseWriter interface.
+func (d *ResponseWriter) WriteMsg(res *dns.Msg) error {
+ // By definition we should sign anything that comes back, we should still figure out for
+ // which zone it should be.
+ state := request.Request{W: d.ResponseWriter, Req: res}
+
+ qname := state.Name()
+ zone := plugin.Zones(d.d.zones).Matches(qname)
+ if zone == "" {
+ return d.ResponseWriter.WriteMsg(res)
+ }
+
+ if state.Do() {
+ res = d.d.Sign(state, zone, time.Now().UTC())
+
+ cacheSize.WithLabelValues("signature").Set(float64(d.d.cache.Len()))
+ }
+ state.SizeAndDo(res)
+
+ return d.ResponseWriter.WriteMsg(res)
+}
+
+// Write implements the dns.ResponseWriter interface.
+func (d *ResponseWriter) Write(buf []byte) (int, error) {
+ log.Printf("[WARNING] Dnssec called with Write: not signing reply")
+ n, err := d.ResponseWriter.Write(buf)
+ return n, err
+}
+
+// Hijack implements the dns.ResponseWriter interface.
+func (d *ResponseWriter) Hijack() { d.ResponseWriter.Hijack() }
diff --git a/plugin/dnssec/rrsig.go b/plugin/dnssec/rrsig.go
new file mode 100644
index 000000000..c68413622
--- /dev/null
+++ b/plugin/dnssec/rrsig.go
@@ -0,0 +1,53 @@
+package dnssec
+
+import "github.com/miekg/dns"
+
+// newRRSIG return a new RRSIG, with all fields filled out, except the signed data.
+func (k *DNSKEY) newRRSIG(signerName string, ttl, incep, expir uint32) *dns.RRSIG {
+ sig := new(dns.RRSIG)
+
+ sig.Hdr.Rrtype = dns.TypeRRSIG
+ sig.Algorithm = k.K.Algorithm
+ sig.KeyTag = k.keytag
+ sig.SignerName = signerName
+ sig.Hdr.Ttl = ttl
+ sig.OrigTtl = origTTL
+
+ sig.Inception = incep
+ sig.Expiration = expir
+
+ return sig
+}
+
+type rrset struct {
+ qname string
+ qtype uint16
+}
+
+// rrSets returns rrs as a map of RRsets. It skips RRSIG and OPT records as those don't need to be signed.
+func rrSets(rrs []dns.RR) map[rrset][]dns.RR {
+ m := make(map[rrset][]dns.RR)
+
+ for _, r := range rrs {
+ if r.Header().Rrtype == dns.TypeRRSIG || r.Header().Rrtype == dns.TypeOPT {
+ continue
+ }
+
+ if s, ok := m[rrset{r.Header().Name, r.Header().Rrtype}]; ok {
+ s = append(s, r)
+ m[rrset{r.Header().Name, r.Header().Rrtype}] = s
+ continue
+ }
+
+ s := make([]dns.RR, 1, 3)
+ s[0] = r
+ m[rrset{r.Header().Name, r.Header().Rrtype}] = s
+ }
+
+ if len(m) > 0 {
+ return m
+ }
+ return nil
+}
+
+const origTTL = 3600
diff --git a/plugin/dnssec/setup.go b/plugin/dnssec/setup.go
new file mode 100644
index 000000000..2f5c21d97
--- /dev/null
+++ b/plugin/dnssec/setup.go
@@ -0,0 +1,128 @@
+package dnssec
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/coredns/coredns/core/dnsserver"
+ "github.com/coredns/coredns/plugin"
+ "github.com/coredns/coredns/plugin/pkg/cache"
+
+ "github.com/mholt/caddy"
+)
+
+func init() {
+ caddy.RegisterPlugin("dnssec", caddy.Plugin{
+ ServerType: "dns",
+ Action: setup,
+ })
+}
+
+func setup(c *caddy.Controller) error {
+ zones, keys, capacity, err := dnssecParse(c)
+ if err != nil {
+ return plugin.Error("dnssec", err)
+ }
+
+ ca := cache.New(capacity)
+ dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
+ return New(zones, keys, next, ca)
+ })
+
+ // Export the capacity for the metrics. This only happens once, because this is a re-load change only.
+ cacheCapacity.WithLabelValues("signature").Set(float64(capacity))
+
+ return nil
+}
+
+func dnssecParse(c *caddy.Controller) ([]string, []*DNSKEY, int, error) {
+ zones := []string{}
+
+ keys := []*DNSKEY{}
+
+ capacity := defaultCap
+ for c.Next() {
+ // dnssec [zones...]
+ zones = make([]string, len(c.ServerBlockKeys))
+ copy(zones, c.ServerBlockKeys)
+ args := c.RemainingArgs()
+ if len(args) > 0 {
+ zones = args
+ }
+
+ for c.NextBlock() {
+ switch c.Val() {
+ case "key":
+ k, e := keyParse(c)
+ if e != nil {
+ return nil, nil, 0, e
+ }
+ keys = append(keys, k...)
+ case "cache_capacity":
+ if !c.NextArg() {
+ return nil, nil, 0, c.ArgErr()
+ }
+ value := c.Val()
+ cacheCap, err := strconv.Atoi(value)
+ if err != nil {
+ return nil, nil, 0, err
+ }
+ capacity = cacheCap
+ }
+
+ }
+ }
+ for i := range zones {
+ zones[i] = plugin.Host(zones[i]).Normalize()
+ }
+
+ // Check if each keys owner name can actually sign the zones we want them to sign
+ for _, k := range keys {
+ kname := plugin.Name(k.K.Header().Name)
+ ok := false
+ for i := range zones {
+ if kname.Matches(zones[i]) {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ return zones, keys, capacity, fmt.Errorf("key %s (keyid: %d) can not sign any of the zones", string(kname), k.keytag)
+ }
+ }
+
+ return zones, keys, capacity, nil
+}
+
+func keyParse(c *caddy.Controller) ([]*DNSKEY, error) {
+ keys := []*DNSKEY{}
+
+ if !c.NextArg() {
+ return nil, c.ArgErr()
+ }
+ value := c.Val()
+ if value == "file" {
+ ks := c.RemainingArgs()
+ if len(ks) == 0 {
+ return nil, c.ArgErr()
+ }
+
+ for _, k := range ks {
+ base := k
+ // Kmiek.nl.+013+26205.key, handle .private or without extension: Kmiek.nl.+013+26205
+ if strings.HasSuffix(k, ".key") {
+ base = k[:len(k)-4]
+ }
+ if strings.HasSuffix(k, ".private") {
+ base = k[:len(k)-8]
+ }
+ k, err := ParseKeyFile(base+".key", base+".private")
+ if err != nil {
+ return nil, err
+ }
+ keys = append(keys, k)
+ }
+ }
+ return keys, nil
+}
diff --git a/plugin/dnssec/setup_test.go b/plugin/dnssec/setup_test.go
new file mode 100644
index 000000000..99a71279d
--- /dev/null
+++ b/plugin/dnssec/setup_test.go
@@ -0,0 +1,120 @@
+package dnssec
+
+import (
+ "io/ioutil"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/mholt/caddy"
+)
+
+func TestSetupDnssec(t *testing.T) {
+ if err := ioutil.WriteFile("Kcluster.local.key", []byte(keypub), 0644); err != nil {
+ t.Fatalf("Failed to write pub key file: %s", err)
+ }
+ defer func() { os.Remove("Kcluster.local.key") }()
+ if err := ioutil.WriteFile("Kcluster.local.private", []byte(keypriv), 0644); err != nil {
+ t.Fatalf("Failed to write private key file: %s", err)
+ }
+ defer func() { os.Remove("Kcluster.local.private") }()
+
+ tests := []struct {
+ input string
+ shouldErr bool
+ expectedZones []string
+ expectedKeys []string
+ expectedCapacity int
+ expectedErrContent string
+ }{
+ {`dnssec`, false, nil, nil, defaultCap, ""},
+ {`dnssec example.org`, false, []string{"example.org."}, nil, defaultCap, ""},
+ {`dnssec 10.0.0.0/8`, false, []string{"10.in-addr.arpa."}, nil, defaultCap, ""},
+ {
+ `dnssec example.org {
+ cache_capacity 100
+ }`, false, []string{"example.org."}, nil, 100, "",
+ },
+ {
+ `dnssec cluster.local {
+ key file Kcluster.local
+ }`, false, []string{"cluster.local."}, nil, defaultCap, "",
+ },
+ {
+ `dnssec example.org cluster.local {
+ key file Kcluster.local
+ }`, false, []string{"example.org.", "cluster.local."}, nil, defaultCap, "",
+ },
+ // fails
+ {
+ `dnssec example.org {
+ key file Kcluster.local
+ }`, true, []string{"example.org."}, nil, defaultCap, "can not sign any",
+ },
+ {
+ `dnssec example.org {
+ key
+ }`, true, []string{"example.org."}, nil, defaultCap, "argument count",
+ },
+ {
+ `dnssec example.org {
+ key file
+ }`, true, []string{"example.org."}, nil, defaultCap, "argument count",
+ },
+ }
+
+ for i, test := range tests {
+ c := caddy.NewTestController("dns", test.input)
+ zones, keys, capacity, err := dnssecParse(c)
+
+ if test.shouldErr && err == nil {
+ t.Errorf("Test %d: Expected error but found %s for input %s", i, err, test.input)
+ }
+
+ if err != nil {
+ if !test.shouldErr {
+ t.Errorf("Test %d: Expected no error but found one for input %s. Error was: %v", i, test.input, err)
+ }
+
+ if !strings.Contains(err.Error(), test.expectedErrContent) {
+ t.Errorf("Test %d: Expected error to contain: %v, found error: %v, input: %s", i, test.expectedErrContent, err, test.input)
+ }
+ }
+ if !test.shouldErr {
+ for i, z := range test.expectedZones {
+ if zones[i] != z {
+ t.Errorf("Dnssec not correctly set for input %s. Expected: %s, actual: %s", test.input, z, zones[i])
+ }
+ }
+ for i, k := range test.expectedKeys {
+ if k != keys[i].K.Header().Name {
+ t.Errorf("Dnssec not correctly set for input %s. Expected: '%s', actual: '%s'", test.input, k, keys[i].K.Header().Name)
+ }
+ }
+ if capacity != test.expectedCapacity {
+ t.Errorf("Dnssec not correctly set capacity for input '%s' Expected: '%d', actual: '%d'", test.input, capacity, test.expectedCapacity)
+ }
+ }
+ }
+}
+
+const keypub = `; This is a zone-signing key, keyid 45330, for cluster.local.
+; Created: 20170901060531 (Fri Sep 1 08:05:31 2017)
+; Publish: 20170901060531 (Fri Sep 1 08:05:31 2017)
+; Activate: 20170901060531 (Fri Sep 1 08:05:31 2017)
+cluster.local. IN DNSKEY 256 3 5 AwEAAcFpDv+Cb23kFJowu+VU++b2N1uEHi6Ll9H0BzLasFOdJjEEclCO q/KlD4682vOMXxJNN8ZwOyiCa7Y0TEYqSwWvhHyn3bHCwuy4I6fss4Wd 7Y9dU+6QTgJ8LimGG40Iizjc9zqoU8Q+q81vIukpYWOHioHoY7hsWBvS RSlzDJk3`
+
+const keypriv = `Private-key-format: v1.3
+Algorithm: 5 (RSASHA1)
+Modulus: wWkO/4JvbeQUmjC75VT75vY3W4QeLouX0fQHMtqwU50mMQRyUI6r8qUPjrza84xfEk03xnA7KIJrtjRMRipLBa+EfKfdscLC7Lgjp+yzhZ3tj11T7pBOAnwuKYYbjQiLONz3OqhTxD6rzW8i6SlhY4eKgehjuGxYG9JFKXMMmTc=
+PublicExponent: AQAB
+PrivateExponent: K5XyZFBPrjMVFX5gCZlyPyVDamNGrfSVXSIiMSqpS96BSdCXtmHAjCj4bZFPwkzi6+vs4tJN8p4ZifEVM0a6qwPZyENBrc2qbsweOXE6l8BaPVWFX30xvVRzGXuNtXxlBXE17zoHty5r5mRyRou1bc2HUS5otdkEjE30RiocQVk=
+Prime1: 7RRFUxaZkVNVH1DaT/SV5Sb8kABB389qLwU++argeDCVf+Wm9BBlTrsz2U6bKlfpaUmYZKtCCd+CVxqzMyuu0w==
+Prime2: 0NiY3d7Fa08IGY9L4TaFc02A721YcDNBBf95BP31qGvwnYsLFM/1xZwaEsIjohg8g+m/GpyIlvNMbK6pywIVjQ==
+Exponent1: XjXO8pype9mMmvwrNNix9DTQ6nxfsQugW30PMHGZ78kGr6NX++bEC0xS50jYWjRDGcbYGzD+9iNujSScD3qNZw==
+Exponent2: wkoOhLIfhUIj7etikyUup2Ld5WAbW15DSrotstg0NrgcQ+Q7reP96BXeJ79WeREFE09cyvv/EjdLzPv81/CbbQ==
+Coefficient: ah4LL0KLTO8kSKHK+X9Ud8grYi94QSNdbX11ge/eFcS/41QhDuZRTAFv4y0+IG+VWd+XzojLsQs+jzLe5GzINg==
+Created: 20170901060531
+Publish: 20170901060531
+Activate: 20170901060531
+`