plugin/sign: a plugin that signs zone (#2993)

* plugin/sign: a plugin that signs zones

Sign is a plugin that signs zone data (on disk). The README.md details
what exactly happens to should be accurate related to the code.

Signs are signed with a CSK, resigning and first time signing is all
handled by *sign* plugin.

Logging with a test zone looks something like this:

~~~ txt
[INFO] plugin/sign: Signing "miek.nl." because open plugin/sign/testdata/db.miek.nl.signed: no such file or directory
[INFO] plugin/sign: Signed "miek.nl." with key tags "59725" in 11.670985ms, saved in "plugin/sign/testdata/db.miek.nl.signed". Next: 2019-07-20T15:49:06.560Z
[INFO] plugin/file: Successfully reloaded zone "miek.nl." in "plugin/sign/testdata/db.miek.nl.signed" with serial 1563636548
[INFO] plugin/sign: Signing "miek.nl." because resign was: 10m0s ago
[INFO] plugin/sign: Signed "miek.nl." with key tags "59725" in 2.055895ms, saved in "plugin/sign/testdata/db.miek.nl.signed". Next: 2019-07-20T16:09:06.560Z
[INFO] plugin/file: Successfully reloaded zone "miek.nl." in "plugin/sign/testdata/db.miek.nl.signed" with serial 1563637748
~~~

Signed-off-by: Miek Gieben <miek@miek.nl>

* Adjust readme and remove timestamps

Signed-off-by: Miek Gieben <miek@miek.nl>

* Comment on the newline

Signed-off-by: Miek Gieben <miek@miek.nl>

* Update plugin/sign/README.md

Co-Authored-By: Michael Grosser <development@stp-ip.net>
This commit is contained in:
Miek Gieben
2019-08-29 15:41:59 +01:00
committed by GitHub
parent eec24cb013
commit b8a0b52a5e
21 changed files with 1114 additions and 1 deletions

View File

@@ -51,4 +51,5 @@ var Directives = []string{
"erratic",
"whoami",
"on",
"sign",
}

View File

@@ -40,6 +40,7 @@ import (
_ "github.com/coredns/coredns/plugin/root"
_ "github.com/coredns/coredns/plugin/route53"
_ "github.com/coredns/coredns/plugin/secondary"
_ "github.com/coredns/coredns/plugin/sign"
_ "github.com/coredns/coredns/plugin/template"
_ "github.com/coredns/coredns/plugin/tls"
_ "github.com/coredns/coredns/plugin/trace"

2
go.mod
View File

@@ -42,7 +42,7 @@ require (
github.com/tinylib/msgp v1.1.0 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 // indirect
go.etcd.io/etcd v0.0.0-20190823073701-67d0c21bb04c
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect
google.golang.org/api v0.9.0

8
go.sum
View File

@@ -353,6 +353,8 @@ golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443 h1:IcSOAf4PyMp3U3XbIEj1/xJ2BjNN2jWv7JoyOsMxXUU=
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -382,6 +384,8 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -416,6 +420,8 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -458,6 +464,8 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190611190212-a7e196e89fd3/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190626174449-989357319d63 h1:UsSJe9fhWNSz6emfIGPpH5DF23t7ALo2Pf3sC+/hsdg=
google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190701230453-710ae3a149df h1:k3DT34vxk64+4bD5x+fRy6U0SXxZehzUHRSYUJcKfII=
google.golang.org/genproto v0.0.0-20190701230453-710ae3a149df/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=

View File

@@ -60,3 +60,4 @@ grpc:grpc
erratic:erratic
whoami:whoami
on:github.com/caddyserver/caddy/onevent
sign:sign

161
plugin/sign/README.md Normal file
View File

@@ -0,0 +1,161 @@
# sign
## Name
*sign* - add DNSSEC records to zone files.
## Description
The *sign* plugin is used to sign (see RFC 6781) zones. In this process DNSSEC resource records are
added. The signatures that sign the resource records sets have an expiration date, this means the
signing process must be repeated before this expiration data is reached. Otherwise the zone's data
will go BAD (RFC 4035, Section 5.5). The *sign* plugin takes care of this. *Sign* works, but has
a couple of limitations, see the "Bugs" section.
Only NSEC is supported, *sign* does not support NSEC3.
*Sign* works in conjunction with the *file* and *auto* plugins; this plugin **signs** the zones
files, *auto* and *file* **serve** the zones *data*.
For this plugin to work at least one Common Signing Key, (see coredns-keygen(1)) is needed. This key
(or keys) will be used to sign the entire zone. *Sign* does not support the ZSK/KSK split, nor will
it do key or algorithm rollovers - it just signs.
*Sign* will:
* (Re)-sign the zone with the CSK(s) when:
- the last time it was signed is more than a 6 days ago. Each zone will have some jitter
applied to the inception date.
- the signature only has 14 days left before expiring.
Both these dates are only checked on the SOA's signature(s).
* Create signatures that have an inception of -3 hours (minus a jitter between 0 and 18 hours)
and a expiration of +32 days for every given DNSKEY.
* Add or replace *all* apex CDS/CDNSKEY records with the ones derived from the given keys. For
each key two CDS are created one with SHA1 and another with SHA256.
* Update the SOA's serial number to the *Unix epoch* of when the signing happens. This will
overwrite *any* previous serial number.
Thus there are two ways that dictate when a zone is signed. Normally every 6 days (plus jitter) it
will be resigned. If for some reason we fail this check, the 14 days before expiring kicks in.
Keys are named (following BIND9): `K<name>+<alg>+<id>.key` and `K<name>+<alg>+<id>.private`.
The keys **must not** be included in your zone; they will be added by *sign*. These keys can be
generated with `coredns-keygen` or BIND9's `dnssec-keygen`. You don't have to adhere to this naming
scheme, but then you need to name your keys explicitly, see the `keys file` directive.
A generated zone is written out in a file named `db.<name>.signed` in the directory named by the
`directory` directive (which defaults to `/var/lib/coredns`).
## Syntax
~~~
sign DBFILE [ZONES...] {
key file|directory KEY...|DIR...
directory DIR
}
~~~
* **DBFILE** the zone database file to read and parse. If the path is relative, the path from the
*root* directive will be prepended to it.
* **ZONES** zones it should be sign for. If empty, the zones from the configuration block are
used.
* `key` specifies the key(s) (there can be multiple) to sign the zone. If `file` is
used the **KEY**'s filenames are used as is. If `directory` is used, *sign* will look in **DIR**
for `K<name>+<alg>+<id>` files. Any metadata in these files (Activate, Publish, etc.) is
*ignored*. These keys must also be Key Signing Keys (KSK).
* `directory` specifies the **DIR** where CoreDNS should save zones that have been signed.
If not given this defaults to `/var/lib/coredns`. The zones are saved under the name
`db.<name>.signed`. If the path is relative the path from the *root* directive will be prepended
to it.
Keys can be generated with `coredns-keygen`, to create one for use in the *sign* plugin, use:
`coredns-keygen example.org` or `dnssec-keygen -a ECDSAP256SHA256 -f KSK example.org`.
## Examples
Sign the `example.org` zone contained in the file `db.example.org` and write the result to
`./db.example.org.signed` to let the *file* plugin pick it up and serve it. The keys used
are read from `/etc/coredns/keys/Kexample.org.key` and `/etc/coredns/keys/Kexample.org.private`.
~~~ txt
example.org {
file db.example.org.signed
sign db.example.org {
key file /etc/coredns/keys/Kexample.org
directory .
}
}
~~~
Running this leads to the following log output (note the timers in this example have been set to
shorter intervals).
~~~ txt
[WARNING] plugin/file: Failed to open "open /tmp/db.example.org.signed: no such file or directory": trying again in 1m0s
[INFO] plugin/sign: Signing "example.org." because open /tmp/db.example.org.signed: no such file or directory
[INFO] plugin/sign: Successfully signed zone "example.org." in "/tmp/db.example.org.signed" with key tags "59725" and 1564766865 SOA serial, elapsed 9.357933ms, next: 2019-08-02T22:27:45.270Z
[INFO] plugin/file: Successfully reloaded zone "example.org." in "/tmp/db.example.org.signed" with serial 1564766865
~~~
Or use a single zone file for *multiple* zones, note that the **ZONES** are repeated for both plugins.
Also note this outputs *multiple* signed output files. Here we use the default output directory
`/var/lib/coredns`.
~~~ txt
. {
file /var/lib/coredns/db.example.org.signed example.org
file /var/lib/coredns/db.example.net.signed example.net
sign db.example.org example.org example.net {
key directory /etc/coredns/keys
}
}
~~~
This is the same configuration, but the zones are put in the server block, but note that you still
need to specify what file is served for what zone in the *file* plugin:
~~~ txt
example.org example.net {
file var/lib/coredns/db.example.org.signed example.org
file var/lib/coredns/db.example.net.signed example.net
sign db.example.org {
key directory /etc/coredns/keys
}
}
~~~
Be careful to fully list the origins you want to sign, if you don't:
~~~ txt
example.org example.net {
sign plugin/sign/testdata/db.example.org miek.org {
key file /etc/coredns/keys/Kexample.org
}
}
~~~
This will lead to `db.example.org` be signed *twice*, as this entire section is parsed twice because
you have specified the origins `example.org` and `example.net` in the server block.
Forcibly resigning a zone can be accomplished by removing the signed zone file (CoreDNS will keep on
serving it from memory), and sending SIGUSR1 to the process to make it reload and resign the zone
file.
## Also See
The DNSSEC RFCs: RFC 4033, RFC 4034 and RFC 4035. And the BCP on DNSSEC, RFC 6781. Further more the
manual pages coredns-keygen(1) and dnssec-keygen(8). And the *file* plugin's documentation.
Coredns-keygen can be found at <https://github.com/coredns/coredns-utils> in the coredns-keygen directory.
## Bugs
`keys directory` is not implemented. Glue records are currently signed, and no DS records are added
for child zones.

20
plugin/sign/dnssec.go Normal file
View File

@@ -0,0 +1,20 @@
package sign
import (
"github.com/miekg/dns"
)
func (p Pair) signRRs(rrs []dns.RR, signerName string, ttl, incep, expir uint32) (*dns.RRSIG, error) {
rrsig := &dns.RRSIG{
Hdr: dns.RR_Header{Rrtype: dns.TypeRRSIG, Ttl: ttl},
Algorithm: p.Public.Algorithm,
SignerName: signerName,
KeyTag: p.KeyTag,
OrigTtl: ttl,
Inception: incep,
Expiration: expir,
}
e := rrsig.Sign(p.Private, rrs)
return rrsig, e
}

93
plugin/sign/file.go Normal file
View File

@@ -0,0 +1,93 @@
package sign
import (
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/file/tree"
"github.com/miekg/dns"
)
// write writes out the zone file to a temporary file which is then moved into the correct place.
func (s *Signer) write(z *file.Zone) error {
f, err := ioutil.TempFile(s.directory, "signed-")
if err != nil {
return err
}
if err := write(f, z); err != nil {
f.Close()
return err
}
f.Close()
return os.Rename(f.Name(), filepath.Join(s.directory, s.signedfile))
}
func write(w io.Writer, z *file.Zone) error {
if _, err := io.WriteString(w, z.Apex.SOA.String()); err != nil {
return err
}
w.Write([]byte("\n")) // RR Stringer() method doesn't include newline, which ends the RR in a zone file, write that here.
for _, rr := range z.Apex.SIGSOA {
io.WriteString(w, rr.String())
w.Write([]byte("\n"))
}
for _, rr := range z.Apex.NS {
io.WriteString(w, rr.String())
w.Write([]byte("\n"))
}
for _, rr := range z.Apex.SIGNS {
io.WriteString(w, rr.String())
w.Write([]byte("\n"))
}
err := z.Walk(func(e *tree.Elem, _ map[uint16][]dns.RR) error {
for _, r := range e.All() {
io.WriteString(w, r.String())
w.Write([]byte("\n"))
}
return nil
})
return err
}
// Parse parses the zone in filename and returns a new Zone or an error. This
// is similar to the Parse function in the *file* plugin. However when parsing
// the record types DNSKEY, RRSIG, CDNSKEY and CDS are *not* included in the returned
// zone (if encountered).
func Parse(f io.Reader, origin, fileName string) (*file.Zone, error) {
zp := dns.NewZoneParser(f, dns.Fqdn(origin), fileName)
zp.SetIncludeAllowed(true)
z := file.NewZone(origin, fileName)
seenSOA := false
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
if err := zp.Err(); err != nil {
return nil, err
}
switch rr.(type) {
case *dns.DNSKEY, *dns.RRSIG, *dns.CDNSKEY, *dns.CDS:
continue
case *dns.SOA:
seenSOA = true
if err := z.Insert(rr); err != nil {
return nil, err
}
default:
if err := z.Insert(rr); err != nil {
return nil, err
}
}
}
if !seenSOA {
return nil, fmt.Errorf("file %q has no SOA record", fileName)
}
return z, nil
}

43
plugin/sign/file_test.go Normal file
View File

@@ -0,0 +1,43 @@
package sign
import (
"os"
"testing"
"github.com/miekg/dns"
)
func TestFileParse(t *testing.T) {
f, err := os.Open("testdata/db.miek.nl")
if err != nil {
t.Fatal(err)
}
z, err := Parse(f, "miek.nl.", "testdata/db.miek.nl")
if err != nil {
t.Fatal(err)
}
s := &Signer{
directory: ".",
signedfile: "db.miek.nl.test",
}
s.write(z)
defer os.Remove("db.miek.nl.test")
f, err = os.Open("db.miek.nl.test")
if err != nil {
t.Fatal(err)
}
z, err = Parse(f, "miek.nl.", "db.miek.nl.test")
if err != nil {
t.Fatal(err)
}
if x := z.Apex.SOA.Header().Name; x != "miek.nl." {
t.Errorf("Expected SOA name to be %s, got %s", x, "miek.nl.")
}
apex, _ := z.Search("miek.nl.")
key := apex.Type(dns.TypeDNSKEY)
if key != nil {
t.Errorf("Expected no DNSKEYs, but got %d", len(key))
}
}

119
plugin/sign/keys.go Normal file
View File

@@ -0,0 +1,119 @@
package sign
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/coredns/coredns/core/dnsserver"
"github.com/caddyserver/caddy"
"github.com/miekg/dns"
"golang.org/x/crypto/ed25519"
)
// Pair holds DNSSEC key information, both the public and private components are stored here.
type Pair struct {
Public *dns.DNSKEY
KeyTag uint16
Private crypto.Signer
}
// keyParse reads the public and private key from disk.
func keyParse(c *caddy.Controller) ([]Pair, error) {
if !c.NextArg() {
return nil, c.ArgErr()
}
pairs := []Pair{}
config := dnsserver.GetConfig(c)
switch c.Val() {
case "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]
}
if !filepath.IsAbs(base) && config.Root != "" {
base = filepath.Join(config.Root, base)
}
pair, err := readKeyPair(base+".key", base+".private")
if err != nil {
return nil, err
}
pairs = append(pairs, pair)
}
case "directory":
return nil, fmt.Errorf("directory: not implemented")
}
return pairs, nil
}
func readKeyPair(public, private string) (Pair, error) {
rk, err := os.Open(public)
if err != nil {
return Pair{}, err
}
b, err := ioutil.ReadAll(rk)
if err != nil {
return Pair{}, err
}
dnskey, err := dns.NewRR(string(b))
if err != nil {
return Pair{}, err
}
if _, ok := dnskey.(*dns.DNSKEY); !ok {
return Pair{}, fmt.Errorf("RR in %q is not a DNSKEY: %d", public, dnskey.Header().Rrtype)
}
ksk := dnskey.(*dns.DNSKEY).Flags&(1<<8) == (1<<8) && dnskey.(*dns.DNSKEY).Flags&1 == 1
if !ksk {
return Pair{}, fmt.Errorf("DNSKEY in %q is not a CSK/KSK", public)
}
rp, err := os.Open(private)
if err != nil {
return Pair{}, err
}
privkey, err := dnskey.(*dns.DNSKEY).ReadPrivateKey(rp, private)
if err != nil {
return Pair{}, err
}
switch signer := privkey.(type) {
case *ecdsa.PrivateKey:
return Pair{Public: dnskey.(*dns.DNSKEY), KeyTag: dnskey.(*dns.DNSKEY).KeyTag(), Private: signer}, nil
case *ed25519.PrivateKey:
return Pair{Public: dnskey.(*dns.DNSKEY), KeyTag: dnskey.(*dns.DNSKEY).KeyTag(), Private: signer}, nil
case *rsa.PrivateKey:
return Pair{Public: dnskey.(*dns.DNSKEY), KeyTag: dnskey.(*dns.DNSKEY).KeyTag(), Private: signer}, nil
default:
return Pair{}, fmt.Errorf("unsupported algorithm %s", signer)
}
}
// keyTag returns the key tags of the keys in ps as a formatted string.
func keyTag(ps []Pair) string {
if len(ps) == 0 {
return ""
}
s := ""
for _, p := range ps {
s += strconv.Itoa(int(p.KeyTag)) + ","
}
return s[:len(s)-1]
}

5
plugin/sign/log_test.go Normal file
View File

@@ -0,0 +1,5 @@
package sign
import clog "github.com/coredns/coredns/plugin/pkg/log"
func init() { clog.Discard() }

41
plugin/sign/nsec.go Normal file
View File

@@ -0,0 +1,41 @@
package sign
import (
"sort"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/file/tree"
"github.com/miekg/dns"
)
// names returns the elements of the zone in nsec order. If the returned boolean is true there were
// no other apex records than SOA and NS, which are stored separately.
func names(origin string, z *file.Zone) ([]string, bool) {
// if there are no apex records other than NS and SOA we'll miss the origin
// in this list. Check the first element and if not origin prepend it.
n := []string{}
z.Walk(func(e *tree.Elem, _ map[uint16][]dns.RR) error {
n = append(n, e.Name())
return nil
})
if len(n) == 0 {
return nil, false
}
if n[0] != origin {
n = append([]string{origin}, n...)
return n, true
}
return n, false
}
// NSEC returns an NSEC record according to name, next, ttl and bitmap. Note that the bitmap is sorted before use.
func NSEC(name, next string, ttl uint32, bitmap []uint16) *dns.NSEC {
sort.Slice(bitmap, func(i, j int) bool { return bitmap[i] < bitmap[j] })
return &dns.NSEC{
Hdr: dns.RR_Header{Name: name, Ttl: ttl, Rrtype: dns.TypeNSEC, Class: dns.ClassINET},
NextDomain: next,
TypeBitMap: bitmap,
}
}

View File

@@ -0,0 +1,42 @@
package sign
import (
"strings"
"testing"
"time"
)
func TestResignInception(t *testing.T) {
then := time.Date(2019, 7, 18, 22, 50, 0, 0, time.UTC)
// signed yesterday
zr := strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190808191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
if x := resign(zr, then); x != nil {
t.Errorf("Expected RRSIG to be valid for %s, got invalid: %s", then.Format(timeFmt), x)
}
// inception starts after this date.
zr = strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190808191936 20190731161936 59725 miek.nl. eU6gI1OkSEbyt`)
if x := resign(zr, then); x == nil {
t.Errorf("Expected RRSIG to be invalid for %s, got valid", then.Format(timeFmt))
}
}
func TestResignExpire(t *testing.T) {
then := time.Date(2019, 7, 18, 22, 50, 0, 0, time.UTC)
// expires tomorrow
zr := strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190717191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
if x := resign(zr, then); x == nil {
t.Errorf("Expected RRSIG to be invalid for %s, got valid", then.Format(timeFmt))
}
// expire too far away
zr = strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190731191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
if x := resign(zr, then); x != nil {
t.Errorf("Expected RRSIG to be valid for %s, got invalid: %s", then.Format(timeFmt), x)
}
// expired yesterday
zr = strings.NewReader(`miek.nl. 1800 IN RRSIG SOA 13 2 1800 20190721191936 20190717161936 59725 miek.nl. eU6gI1OkSEbyt`)
if x := resign(zr, then); x == nil {
t.Errorf("Expected RRSIG to be invalid for %s, got valid", then.Format(timeFmt))
}
}

114
plugin/sign/setup.go Normal file
View File

@@ -0,0 +1,114 @@
package sign
import (
"fmt"
"math/rand"
"path/filepath"
"time"
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/plugin"
"github.com/caddyserver/caddy"
)
func init() {
caddy.RegisterPlugin("sign", caddy.Plugin{
ServerType: "dns",
Action: setup,
})
}
func setup(c *caddy.Controller) error {
sign, err := parse(c)
if err != nil {
return plugin.Error("sign", err)
}
c.OnStartup(sign.OnStartup)
c.OnStartup(func() error {
for _, signer := range sign.signers {
go signer.refresh(DurationRefreshHours)
}
return nil
})
c.OnShutdown(func() error {
for _, signer := range sign.signers {
close(signer.stop)
}
return nil
})
// Don't call AddPlugin, *sign* is not a plugin.
return nil
}
func parse(c *caddy.Controller) (*Sign, error) {
sign := &Sign{}
config := dnsserver.GetConfig(c)
for c.Next() {
if !c.NextArg() {
return nil, c.ArgErr()
}
dbfile := c.Val()
if !filepath.IsAbs(dbfile) && config.Root != "" {
dbfile = filepath.Join(config.Root, dbfile)
}
origins := make([]string, len(c.ServerBlockKeys))
copy(origins, c.ServerBlockKeys)
args := c.RemainingArgs()
if len(args) > 0 {
origins = args
}
for i := range origins {
origins[i] = plugin.Host(origins[i]).Normalize()
}
signers := make([]*Signer, len(origins))
for i := range origins {
signers[i] = &Signer{
dbfile: dbfile,
origin: plugin.Host(origins[i]).Normalize(),
jitter: time.Duration(float32(DurationJitter) * rand.Float32()),
directory: "/var/lib/coredns",
stop: make(chan struct{}),
signedfile: fmt.Sprintf("db.%ssigned", origins[i]), // origins[i] is a fqdn, so it ends with a dot, hence %ssigned.
}
}
for c.NextBlock() {
switch c.Val() {
case "key":
pairs, err := keyParse(c)
if err != nil {
return sign, err
}
for i := range signers {
for _, p := range pairs {
p.Public.Header().Name = signers[i].origin
}
signers[i].keys = append(signers[i].keys, pairs...)
}
case "directory":
dir := c.RemainingArgs()
if len(dir) == 0 || len(dir) > 1 {
return sign, fmt.Errorf("can only be one argument after %q", "directory")
}
if !filepath.IsAbs(dir[0]) && config.Root != "" {
dir[0] = filepath.Join(config.Root, dir[0])
}
for i := range signers {
signers[i].directory = dir[0]
signers[i].signedfile = fmt.Sprintf("db.%ssigned", signers[i].origin)
}
default:
return nil, c.Errf("unknown property '%s'", c.Val())
}
}
sign.signers = append(sign.signers, signers...)
}
return sign, nil
}

75
plugin/sign/setup_test.go Normal file
View File

@@ -0,0 +1,75 @@
package sign
import (
"testing"
"github.com/caddyserver/caddy"
)
func TestParse(t *testing.T) {
tests := []struct {
input string
shouldErr bool
exp *Signer
}{
{`sign testdata/db.miek.nl miek.nl {
key file testdata/Kmiek.nl.+013+59725
}`,
false,
&Signer{
keys: []Pair{},
origin: "miek.nl.",
dbfile: "testdata/db.miek.nl",
directory: "/var/lib/coredns",
signedfile: "db.miek.nl.signed",
},
},
{`sign testdata/db.miek.nl example.org {
key file testdata/Kmiek.nl.+013+59725
directory testdata
}`,
false,
&Signer{
keys: []Pair{},
origin: "example.org.",
dbfile: "testdata/db.miek.nl",
directory: "testdata",
signedfile: "db.example.org.signed",
},
},
// errors
{`sign db.example.org {
key file /etc/coredns/keys/Kexample.org
}`,
true,
nil,
},
}
for i, tc := range tests {
c := caddy.NewTestController("dns", tc.input)
sign, err := parse(c)
if err == nil && tc.shouldErr {
t.Fatalf("Test %d expected errors, but got no error", i)
}
if err != nil && !tc.shouldErr {
t.Fatalf("Test %d expected no errors, but got '%v'", i, err)
}
if tc.shouldErr {
continue
}
signer := sign.signers[0]
if x := signer.origin; x != tc.exp.origin {
t.Errorf("Test %d expected %s as origin, got %s", i, tc.exp.origin, x)
}
if x := signer.dbfile; x != tc.exp.dbfile {
t.Errorf("Test %d expected %s as dbfile, got %s", i, tc.exp.dbfile, x)
}
if x := signer.directory; x != tc.exp.directory {
t.Errorf("Test %d expected %s as directory, got %s", i, tc.exp.directory, x)
}
if x := signer.signedfile; x != tc.exp.signedfile {
t.Errorf("Test %d expected %s as signedfile, got %s", i, tc.exp.signedfile, x)
}
}
}

37
plugin/sign/sign.go Normal file
View File

@@ -0,0 +1,37 @@
// Package sign implements a zone signer as a plugin.
package sign
import (
"path/filepath"
"time"
)
// Sign contains signers that sign the zones files.
type Sign struct {
signers []*Signer
}
// OnStartup scans all signers and signs or resigns zones if needed.
func (s *Sign) OnStartup() error {
for _, signer := range s.signers {
why := signer.resign()
if why == nil {
log.Infof("Skipping signing zone %q in %q: signatures are valid", signer.origin, filepath.Join(signer.directory, signer.signedfile))
continue
}
go signAndLog(signer, why)
}
return nil
}
// Various duration constants for signing of the zones.
const (
DurationExpireDays = 7 * 24 * time.Hour // max time allowed before expiration
DurationResignDays = 6 * 24 * time.Hour // if the last sign happenend this long ago, sign again
DurationSignatureExpireDays = 32 * 24 * time.Hour // sign for 32 days
DurationRefreshHours = 5 * time.Hour // check zones every 5 hours
DurationJitter = -18 * time.Hour // default max jitter
DurationSignatureInceptionHours = -3 * time.Hour // -(2+1) hours, be sure to catch daylight saving time and such, jitter is substracted
)
const timeFmt = "2006-01-02T15:04:05.000Z07:00"

222
plugin/sign/signer.go Normal file
View File

@@ -0,0 +1,222 @@
package sign
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/file/tree"
clog "github.com/coredns/coredns/plugin/pkg/log"
"github.com/miekg/dns"
)
var log = clog.NewWithPlugin("sign")
// Signer holds the data needed to sign a zone file.
type Signer struct {
keys []Pair
origin string
dbfile string
directory string
jitter time.Duration
signedfile string
stop chan struct{}
expiration uint32
inception uint32
ttl uint32
}
// Sign signs a zone file according to the parameters in s.
func (s *Signer) Sign(now time.Time) (*file.Zone, error) {
rd, err := os.Open(s.dbfile)
if err != nil {
return nil, err
}
z, err := Parse(rd, s.origin, s.dbfile)
if err != nil {
return nil, err
}
s.inception, s.expiration = lifetime(now, s.jitter)
s.ttl = z.Apex.SOA.Header().Ttl
z.Apex.SOA.Serial = uint32(now.Unix())
for _, pair := range s.keys {
pair.Public.Header().Ttl = s.ttl // set TTL on key so it matches the RRSIG.
z.Insert(pair.Public)
z.Insert(pair.Public.ToDS(dns.SHA1))
z.Insert(pair.Public.ToDS(dns.SHA256))
z.Insert(pair.Public.ToDS(dns.SHA1).ToCDS())
z.Insert(pair.Public.ToDS(dns.SHA256).ToCDS())
z.Insert(pair.Public.ToCDNSKEY())
}
names, apex := names(s.origin, z)
ln := len(names)
var nsec *dns.NSEC
if apex {
nsec = NSEC(s.origin, names[(ln+1)%ln], s.ttl, []uint16{dns.TypeSOA, dns.TypeNS, dns.TypeRRSIG, dns.TypeNSEC})
z.Insert(nsec)
}
for _, pair := range s.keys {
rrsig, err := pair.signRRs([]dns.RR{z.Apex.SOA}, s.origin, s.ttl, s.inception, s.expiration)
if err != nil {
return nil, err
}
z.Insert(rrsig)
// NS apex may not be set if RR's have been discarded because the origin doesn't match.
if len(z.Apex.NS) > 0 {
rrsig, err = pair.signRRs(z.Apex.NS, s.origin, s.ttl, s.inception, s.expiration)
if err != nil {
return nil, err
}
z.Insert(rrsig)
}
if apex {
rrsig, err = pair.signRRs([]dns.RR{nsec}, s.origin, s.ttl, s.inception, s.expiration)
if err != nil {
return nil, err
}
z.Insert(rrsig)
}
}
// We are walking the tree in the same direction, so names[] can be used here to indicated the next element.
i := 1
err = z.Walk(func(e *tree.Elem, zrrs map[uint16][]dns.RR) error {
if !apex && e.Name() == s.origin {
nsec := NSEC(e.Name(), names[(ln+i)%ln], s.ttl, append(e.Types(), dns.TypeNS, dns.TypeSOA, dns.TypeNSEC, dns.TypeRRSIG))
z.Insert(nsec)
} else {
nsec := NSEC(e.Name(), names[(ln+i)%ln], s.ttl, append(e.Types(), dns.TypeNSEC, dns.TypeRRSIG))
z.Insert(nsec)
}
for t, rrs := range zrrs {
if t == dns.TypeRRSIG {
continue
}
for _, pair := range s.keys {
rrsig, err := pair.signRRs(rrs, s.origin, s.ttl, s.inception, s.expiration)
if err != nil {
return err
}
e.Insert(rrsig)
}
}
i++
return nil
})
return z, err
}
// resign checks if the signed zone exists, or needs resigning.
func (s *Signer) resign() error {
signedfile := filepath.Join(s.directory, s.signedfile)
rd, err := os.Open(signedfile)
if err != nil && os.IsNotExist(err) {
return err
}
now := time.Now().UTC()
return resign(rd, now)
}
// resign will scan rd and check the signature on the SOA record. We will resign on the basis
// of 2 conditions:
// * either the inception is more than 6 days ago, or
// * we only have 1 week left on the signature
//
// All SOA signatures will be checked. If the SOA isn't found in the first 100
// records, we will resign the zone.
func resign(rd io.Reader, now time.Time) (why error) {
zp := dns.NewZoneParser(rd, ".", "resign")
zp.SetIncludeAllowed(true)
i := 0
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
if err := zp.Err(); err != nil {
return err
}
switch x := rr.(type) {
case *dns.RRSIG:
if x.TypeCovered != dns.TypeSOA {
continue
}
incep, _ := time.Parse("20060102150405", dns.TimeToString(x.Inception))
// If too long ago, resign.
if now.Sub(incep) >= 0 && now.Sub(incep) > DurationResignDays {
return fmt.Errorf("inception %q was more than: %s ago from %s: %s", incep.Format(timeFmt), DurationResignDays, now.Format(timeFmt), now.Sub(incep))
}
// Inception hasn't even start yet.
if now.Sub(incep) < 0 {
return fmt.Errorf("inception %q date is in the future: %s", incep.Format(timeFmt), now.Sub(incep))
}
expire, _ := time.Parse("20060102150405", dns.TimeToString(x.Expiration))
if expire.Sub(now) < DurationExpireDays {
return fmt.Errorf("expiration %q is less than: %s away from %s: %s", expire.Format(timeFmt), DurationExpireDays, now.Format(timeFmt), expire.Sub(now))
}
}
i++
if i > 100 {
// 100 is a random number. A SOA record should be the first in the zonefile, but RFC 1035 doesn't actually mandate this. So it could
// be 3rd or even later. The number 100 looks crazy high enough that it will catch all weird zones, but not high enough to keep the CPU
// busy with parsing all the time.
return fmt.Errorf("no SOA RRSIG found in first 100 records")
}
}
return nil
}
func signAndLog(s *Signer, why error) {
now := time.Now().UTC()
z, err := s.Sign(now)
log.Infof("Signing %q because %s", s.origin, why)
if err != nil {
log.Warningf("Error signing %q with key tags %q in %s: %s, next: %s", s.origin, keyTag(s.keys), time.Since(now), err, now.Add(DurationRefreshHours).Format(timeFmt))
return
}
if err := s.write(z); err != nil {
log.Warningf("Error signing %q: failed to move zone file into place: %s", s.origin, err)
return
}
log.Infof("Successfully signed zone %q in %q with key tags %q and %d SOA serial, elapsed %f, next: %s", s.origin, filepath.Join(s.directory, s.signedfile), keyTag(s.keys), z.Apex.SOA.Serial, time.Since(now).Seconds(), now.Add(DurationRefreshHours).Format(timeFmt))
}
// refresh checks every val if some zones need to be resigned.
func (s *Signer) refresh(val time.Duration) {
tick := time.NewTicker(val)
defer tick.Stop()
for {
select {
case <-s.stop:
return
case <-tick.C:
why := s.resign()
if why == nil {
continue
}
signAndLog(s, why)
}
}
}
func lifetime(now time.Time, jitter time.Duration) (uint32, uint32) {
incep := uint32(now.Add(DurationSignatureInceptionHours).Add(jitter).Unix())
expir := uint32(now.Add(DurationSignatureExpireDays).Unix())
return incep, expir
}

102
plugin/sign/signer_test.go Normal file
View File

@@ -0,0 +1,102 @@
package sign
import (
"io/ioutil"
"os"
"testing"
"time"
"github.com/caddyserver/caddy"
"github.com/miekg/dns"
)
func TestSign(t *testing.T) {
input := `sign testdata/db.miek.nl miek.nl {
key file testdata/Kmiek.nl.+013+59725
directory testdata
}`
c := caddy.NewTestController("dns", input)
sign, err := parse(c)
if err != nil {
t.Fatal(err)
}
if len(sign.signers) != 1 {
t.Fatalf("Expected 1 signer, got %d", len(sign.signers))
}
z, err := sign.signers[0].Sign(time.Now().UTC())
if err != nil {
t.Error(err)
}
apex, _ := z.Search("miek.nl.")
if x := apex.Type(dns.TypeDS); len(x) != 2 {
t.Errorf("Expected %d DS records, got %d", 2, len(x))
}
if x := apex.Type(dns.TypeCDS); len(x) != 2 {
t.Errorf("Expected %d CDS records, got %d", 2, len(x))
}
if x := apex.Type(dns.TypeCDNSKEY); len(x) != 1 {
t.Errorf("Expected %d CDNSKEY record, got %d", 1, len(x))
}
if x := apex.Type(dns.TypeDNSKEY); len(x) != 1 {
t.Errorf("Expected %d DNSKEY record, got %d", 1, len(x))
}
}
func TestSignApexZone(t *testing.T) {
apex := `$TTL 30M
$ORIGIN example.org.
@ IN SOA linode miek.miek.nl. ( 1282630060 4H 1H 7D 4H )
IN NS linode
`
if err := ioutil.WriteFile("db.apex-test.example.org", []byte(apex), 0644); err != nil {
t.Fatal(err)
}
defer os.Remove("db.apex-test.example.org")
input := `sign db.apex-test.example.org example.org {
key file testdata/Kmiek.nl.+013+59725
directory testdata
}`
c := caddy.NewTestController("dns", input)
sign, err := parse(c)
if err != nil {
t.Fatal(err)
}
z, err := sign.signers[0].Sign(time.Now().UTC())
if err != nil {
t.Error(err)
}
el, _ := z.Search("example.org.")
nsec := el.Type(dns.TypeNSEC)
if len(nsec) != 1 {
t.Errorf("Expected 1 NSEC for %s, got %d", "example.org.", len(nsec))
}
if x := nsec[0].(*dns.NSEC).NextDomain; x != "example.org." {
t.Errorf("Expected NSEC NextDomain %s, got %s", "example.org.", x)
}
if x := nsec[0].(*dns.NSEC).TypeBitMap; len(x) != 8 {
t.Errorf("Expected NSEC bitmap to be %d elements, got %d", 8, x)
}
if x := nsec[0].(*dns.NSEC).TypeBitMap; x[7] != dns.TypeCDNSKEY {
t.Errorf("Expected NSEC bitmap element 6 to be %d, got %d", dns.TypeCDNSKEY, x[7])
}
if x := nsec[0].(*dns.NSEC).TypeBitMap; x[5] != dns.TypeDNSKEY {
t.Errorf("Expected NSEC bitmap element 5 to be %d, got %d", dns.TypeDNSKEY, x[5])
}
dnskey := el.Type(dns.TypeDNSKEY)
if x := dnskey[0].Header().Ttl; x != 1800 {
t.Errorf("Expected DNSKEY TTL to be %d, got %d", 1800, x)
}
sigs := el.Type(dns.TypeRRSIG)
for _, s := range sigs {
if s.(*dns.RRSIG).TypeCovered == dns.TypeDNSKEY {
if s.(*dns.RRSIG).OrigTtl != dnskey[0].Header().Ttl {
t.Errorf("Expected RRSIG original TTL to match DNSKEY TTL, but %d != %d", s.(*dns.RRSIG).OrigTtl, dnskey[0].Header().Ttl)
}
if s.(*dns.RRSIG).SignerName != dnskey[0].Header().Name {
t.Errorf("Expected RRSIG signer name to match DNSKEY ownername, but %s != %s", s.(*dns.RRSIG).SignerName, dnskey[0].Header().Name)
}
}
}
}

View File

@@ -0,0 +1,5 @@
; This is a key-signing key, keyid 59725, for miek.nl.
; Created: 20190709192036 (Tue Jul 9 20:20:36 2019)
; Publish: 20190709192036 (Tue Jul 9 20:20:36 2019)
; Activate: 20190709192036 (Tue Jul 9 20:20:36 2019)
miek.nl. IN DNSKEY 257 3 13 sfzRg5nDVxbeUc51su4MzjgwpOpUwnuu81SlRHqJuXe3SOYOeypR69tZ 52XLmE56TAmPHsiB8Rgk+NTpf0o1Cw==

View File

@@ -0,0 +1,6 @@
Private-key-format: v1.3
Algorithm: 13 (ECDSAP256SHA256)
PrivateKey: rm7EdHRca//6xKpJzeoLt/mrfgQnltJ0WpQGtOG59yo=
Created: 20190709192036
Publish: 20190709192036
Activate: 20190709192036

17
plugin/sign/testdata/db.miek.nl vendored Normal file
View File

@@ -0,0 +1,17 @@
$TTL 30M
$ORIGIN miek.nl.
@ IN SOA linode.atoom.net. miek.miek.nl. ( 1282630060 4H 1H 7D 4H )
IN NS linode.atoom.net.
IN MX 1 aspmx.l.google.com.
IN AAAA 2a01:7e00::f03c:91ff:fe79:234c
IN DNSKEY 257 3 13 sfzRg5nDVxbeUc51su4MzjgwpOpUwnuu81SlRHqJuXe3SOYOeypR69tZ52XLmE56TAmPHsiB8Rgk+NTpf0o1Cw==
a IN AAAA 2a01:7e00::f03c:91ff:fe79:234c
www IN CNAME a
bla IN NS ns1.bla.com.
ns3.blaaat.miek.nl. IN AAAA ::1 ; non-glue, should be signed.
; in baliwick nameserver that requires glue, should not be signed
bla IN NS ns2.bla.miek.nl.
ns2.bla.miek.nl. IN A 127.0.0.1