// 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.++.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 }