Files
jarl/internal/dnssec/dnssec.go
T
abrown38andClaude Opus 5.5 5630e7a848 Add DNSSEC signing, key backups and Porkbun support
Zones get a DNSSEC setting: named signs them with BIND's default (or a
custom) dnssec-policy and inline signing, with keys in a per-view key
directory. Jarl shows the keys, the DS record and when it is safe to
publish it, checks the parent's DS records (check-ds, which also tells
named), and refuses steps that would break a signed domain: switching
straight to unsigned, finishing a turn-off too early, deleting or moving
a signed zone, TTLs above max-zone-ttl. Turning off goes through the
insecure policy; wipe starts over once the parent has no DS.

Backups store the DNSSEC keys and restore puts them back for named; the
backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's
serial above named's whenever it writes an older one (after a restore).
The serials check compares the signed serial.

With a Porkbun API key in jarl.yaml, the registrar's name servers, glue
records and DS records can be shown and changed, each change checked
first (dry run, force to override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:19:04 +00:00

321 lines
8.8 KiB
Go

// Package dnssec reads the DNSSEC keys named keeps for the zones it signs
// (dnssec-policy with inline signing) and works out where a zone is in the
// steps of turning signing on or off.
//
// named writes three files per key into the zone's key-directory:
// K<zone>.+<alg>+<tag>.key (the public DNSKEY, world-readable),
// .private (the private key, readable only by named's user) and .state
// (the key's timing and state, see the dnssec-keymgr states in the BIND
// ARM). Jarl only reads the .key and .state files.
package dnssec
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
)
// Key states, as named writes them in .state files.
const (
Hidden = "hidden" // not in the zone (or the parent)
Rumoured = "rumoured" // published, but caches may not have it yet
Omnipresent = "omnipresent" // published long enough that everyone has it
Unretentive = "unretentive" // removed, but caches may still have it
)
// Status describes how a zone is signed.
type Status struct {
Policy string `json:"policy"`
// KeyDir is the zone's key-directory.
KeyDir string `json:"key_dir"`
Keys []*Key `json:"keys"`
Phase Phase `json:"phase"`
Named *NamedSigning `json:"named,omitempty"`
// NamedError says why named couldn't be asked.
NamedError string `json:"named_error,omitempty"`
// KeyError says why the key directory couldn't be read.
KeyError string `json:"key_error,omitempty"`
// Policies are the policies the zone could use: default and the
// custom dnssec-policy statements.
Policies []string `json:"policies"`
}
// NamedSigning is what named reports about a zone it signs.
type NamedSigning struct {
Secure bool `json:"secure"`
InlineSigning bool `json:"inline_signing"`
Serial uint32 `json:"serial"`
SignedSerial uint32 `json:"signed_serial,omitempty"`
NextKeyEvent string `json:"next_key_event,omitempty"`
}
// DS is a DS record for a key: what the parent zone publishes to vouch
// for it.
type DS struct {
KeyTag uint16 `json:"key_tag"`
Algorithm uint8 `json:"algorithm"`
DigestType uint8 `json:"digest_type"`
Digest string `json:"digest"`
// Record is the DS record in zone file format.
Record string `json:"record"`
}
// Key is one DNSSEC key of a zone.
type Key struct {
Tag uint16 `json:"tag"`
Algorithm uint8 `json:"algorithm"`
AlgorithmName string `json:"algorithm_name"`
// Role is CSK (signs everything: BIND's default policy), KSK (signs
// the DNSKEY set; the parent's DS points at it) or ZSK.
Role string `json:"role"`
// DNSKEY is the public key record, and DS its DS record (KSK and CSK
// only).
DNSKEY string `json:"dnskey,omitempty"`
DS *DS `json:"ds,omitempty"`
// States from the .state file.
Goal string `json:"goal,omitempty"`
DNSKEYState string `json:"dnskey_state,omitempty"`
DSState string `json:"ds_state,omitempty"`
ZoneRRSIG string `json:"zone_rrsig_state,omitempty"`
KeyRRSIG string `json:"key_rrsig_state,omitempty"`
// Times from the .state file.
Created *time.Time `json:"created,omitempty"`
Published *time.Time `json:"published,omitempty"`
Active *time.Time `json:"active,omitempty"`
Retired *time.Time `json:"retired,omitempty"`
Removed *time.Time `json:"removed,omitempty"`
// PublishCDS is when named publishes CDS/CDNSKEY records: from then on
// the DS can safely be added at the parent.
PublishCDS *time.Time `json:"publish_cds,omitempty"`
// DSPublished and DSRemoved are when named was told (or saw) that the
// parent added or removed the DS.
DSPublished *time.Time `json:"ds_published,omitempty"`
DSRemoved *time.Time `json:"ds_removed,omitempty"`
// Files are the key's file names in the key directory.
Files []string `json:"files"`
}
// SigningKey reports whether the parent's DS should point at the key.
func (k *Key) SigningKey() bool { return k.Role == "CSK" || k.Role == "KSK" }
// Gone reports whether named has finished removing the key: every state
// is hidden.
func (k *Key) Gone() bool {
for _, s := range []string{k.Goal, k.DNSKEYState, k.DSState, k.ZoneRRSIG, k.KeyRRSIG} {
if s != "" && s != Hidden {
return false
}
}
return true
}
// FilePrefix is how named's key files for zone (a name with the trailing
// dot) begin.
func FilePrefix(zone string) string { return "K" + strings.ToLower(zone) + "+" }
// Files lists the names of zone's key files in dir (every .key, .private
// and .state file, and any other file named with the zone's key prefix).
// A missing directory has none.
func Files(dir, zone string) ([]string, error) {
ents, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
prefix := FilePrefix(zone)
var out []string
for _, e := range ents {
if !e.IsDir() && strings.HasPrefix(strings.ToLower(e.Name()), strings.ToLower(prefix)) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out, nil
}
// Keys reads zone's keys from dir, ordered with the signing keys first.
func Keys(dir, zone string) ([]*Key, error) {
files, err := Files(dir, zone)
if err != nil {
return nil, err
}
byBase := map[string]*Key{}
var order []string
for _, f := range files {
ext := filepath.Ext(f)
base := strings.TrimSuffix(f, ext)
k := byBase[base]
if k == nil {
k = &Key{}
byBase[base] = k
order = append(order, base)
}
k.Files = append(k.Files, f)
switch ext {
case ".key":
if err := k.readPublic(filepath.Join(dir, f)); err != nil {
return nil, err
}
case ".state":
if err := k.readState(filepath.Join(dir, f)); err != nil {
return nil, err
}
}
}
var out []*Key
for _, b := range order {
k := byBase[b]
if k.DNSKEY == "" && k.Goal == "" {
continue // only a .private file: nothing to show
}
out = append(out, k)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].SigningKey() != out[j].SigningKey() {
return out[i].SigningKey()
}
return out[i].Tag < out[j].Tag
})
return out, nil
}
func (k *Key) readPublic(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
zp := dns.NewZoneParser(strings.NewReader(string(b)), "", path)
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
key, isKey := rr.(*dns.DNSKEY)
if !isKey {
continue
}
k.Tag = key.KeyTag()
k.Algorithm = key.Algorithm
k.AlgorithmName = dns.AlgorithmToString[key.Algorithm]
k.DNSKEY = key.String()
if k.Role == "" {
switch {
case key.Flags&dns.SEP != 0:
k.Role = "KSK"
default:
k.Role = "ZSK"
}
}
if key.Flags&dns.SEP != 0 {
if ds := key.ToDS(dns.SHA256); ds != nil {
k.DS = &DS{KeyTag: ds.KeyTag, Algorithm: ds.Algorithm, DigestType: ds.DigestType, Digest: strings.ToUpper(ds.Digest), Record: ds.String()}
}
}
return nil
}
if err := zp.Err(); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return fmt.Errorf("%s: no DNSKEY record", path)
}
func (k *Key) readState(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
var ksk, zsk bool
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, ";") {
continue
}
name, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
v = strings.TrimSpace(v)
t := func() *time.Time {
// 20261008190331 (Thu Oct 8 19:03:31 2026)
f := strings.Fields(v)
if len(f) == 0 {
return nil
}
ts, err := time.Parse("20060102150405", f[0])
if err != nil {
return nil
}
return &ts
}
switch name {
case "Algorithm":
if n, err := strconv.Atoi(v); err == nil && k.Algorithm == 0 {
k.Algorithm = uint8(n)
k.AlgorithmName = dns.AlgorithmToString[uint8(n)]
}
case "KSK":
ksk = v == "yes"
case "ZSK":
zsk = v == "yes"
case "Generated":
k.Created = t()
case "Published":
k.Published = t()
case "Active":
k.Active = t()
case "Retired":
k.Retired = t()
case "Removed":
k.Removed = t()
case "PublishCDS":
k.PublishCDS = t()
case "DSPublish":
k.DSPublished = t()
case "DSRemoved":
k.DSRemoved = t()
case "GoalState":
k.Goal = v
case "DNSKEYState":
k.DNSKEYState = v
case "DSState":
k.DSState = v
case "ZRRSIGState":
k.ZoneRRSIG = v
case "KRRSIGState":
k.KeyRRSIG = v
}
}
switch {
case ksk && zsk:
k.Role = "CSK"
case ksk:
k.Role = "KSK"
case zsk:
k.Role = "ZSK"
}
return sc.Err()
}
// ZoneFileSuffixes are the files named keeps next to a zone file: the
// journal of the zone Jarl wrote (.jnl, and .jbk while it rewrites it) and,
// for a signed zone, the signed copy and its journal.
var ZoneFileSuffixes = []string{".jnl", ".jbk", ".signed", ".signed.jnl", ".signed.jbk"}
// DerivedFiles returns the files named made next to zoneFile that exist.
func DerivedFiles(zoneFile string) []string {
var out []string
for _, s := range ZoneFileSuffixes {
if _, err := os.Lstat(zoneFile + s); err == nil {
out = append(out, zoneFile+s)
}
}
return out
}