Parse k=, h=, a= tags and derive RSA key bit-length from the public key so consumers can detect weak configurations (SHA-1, short keys). Scoring now penalises rsa-sha1 (cap 60), RSA <1024 bit (cap 25), and RSA <2048 bit (cap 75); Ed25519 receives no penalty. Fixes: #37
260 lines
6.7 KiB
Go
260 lines
6.7 KiB
Go
// This file is part of the happyDeliver (R) project.
|
|
// Copyright (c) 2025 happyDomain
|
|
// Authors: Pierre-Olivier Mercier, et al.
|
|
//
|
|
// This program is offered under a commercial and under the AGPL license.
|
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
|
//
|
|
// For AGPL licensing:
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package analyzer
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.happydns.org/happyDeliver/internal/model"
|
|
"git.happydns.org/happyDeliver/internal/utils"
|
|
)
|
|
|
|
// DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header.
|
|
type DKIMHeader struct {
|
|
Domain string
|
|
Selector string
|
|
Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256)
|
|
}
|
|
|
|
// parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values.
|
|
func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
|
var results []DKIMHeader
|
|
for _, sig := range signatures {
|
|
var domain, selector, algorithm string
|
|
for _, part := range strings.Split(sig, ";") {
|
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(kv[0])
|
|
val := strings.TrimSpace(kv[1])
|
|
switch key {
|
|
case "d":
|
|
domain = val
|
|
case "s":
|
|
selector = val
|
|
case "a":
|
|
algorithm = val
|
|
}
|
|
}
|
|
if domain != "" && selector != "" {
|
|
results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// parseDKIMTags splits a DKIM DNS record into a tag→value map.
|
|
func parseDKIMTags(record string) map[string]string {
|
|
tags := make(map[string]string)
|
|
for _, part := range strings.Split(record, ";") {
|
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
|
}
|
|
return tags
|
|
}
|
|
|
|
// parseKeySize derives the public key bit length from a base64-encoded DER public key.
|
|
// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256.
|
|
func parseKeySize(keyType, p string) *int {
|
|
switch strings.ToLower(keyType) {
|
|
case "ed25519":
|
|
return utils.PtrTo(256)
|
|
case "rsa", "":
|
|
der, err := base64.StdEncoding.DecodeString(p)
|
|
if err != nil {
|
|
// Try without padding
|
|
der, err = base64.RawStdEncoding.DecodeString(p)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
pub, err := x509.ParsePKIXPublicKey(der)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if rsaPub, ok := pub.(interface{ Size() int }); ok {
|
|
bits := rsaPub.Size() * 8
|
|
return &bits
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkDKIMRecord looks up and validates DKIM record for a domain and selector.
|
|
func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
|
|
dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
|
defer cancel()
|
|
|
|
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
|
if err != nil {
|
|
return &model.DKIMRecord{
|
|
Selector: h.Selector,
|
|
Domain: h.Domain,
|
|
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
Valid: false,
|
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
|
}
|
|
}
|
|
|
|
if len(txtRecords) == 0 {
|
|
return &model.DKIMRecord{
|
|
Selector: h.Selector,
|
|
Domain: h.Domain,
|
|
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
Valid: false,
|
|
Error: utils.PtrTo("No DKIM record found"),
|
|
}
|
|
}
|
|
|
|
// Concatenate all TXT record parts (DKIM can be split)
|
|
dkimRecord := strings.Join(txtRecords, "")
|
|
|
|
if !d.validateDKIM(dkimRecord) {
|
|
return &model.DKIMRecord{
|
|
Selector: h.Selector,
|
|
Domain: h.Domain,
|
|
Record: utils.PtrTo(dkimRecord),
|
|
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
Valid: false,
|
|
Error: utils.PtrTo("DKIM record appears malformed"),
|
|
}
|
|
}
|
|
|
|
tags := parseDKIMTags(dkimRecord)
|
|
|
|
keyType := tags["k"]
|
|
if keyType == "" {
|
|
keyType = "rsa" // RFC 6376 default
|
|
}
|
|
|
|
var hashAlgorithms []string
|
|
if h, ok := tags["h"]; ok && h != "" {
|
|
for _, alg := range strings.Split(h, ":") {
|
|
if a := strings.TrimSpace(alg); a != "" {
|
|
hashAlgorithms = append(hashAlgorithms, a)
|
|
}
|
|
}
|
|
}
|
|
if hashAlgorithms == nil {
|
|
hashAlgorithms = []string{}
|
|
}
|
|
|
|
return &model.DKIMRecord{
|
|
Selector: h.Selector,
|
|
Domain: h.Domain,
|
|
Record: &dkimRecord,
|
|
KeyType: utils.PtrTo(keyType),
|
|
HashAlgorithms: &hashAlgorithms,
|
|
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
|
KeySize: parseKeySize(keyType, tags["p"]),
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
func signingAlgorithmPtr(a string) *string {
|
|
if a == "" {
|
|
return nil
|
|
}
|
|
return &a
|
|
}
|
|
|
|
// validateDKIM performs basic DKIM record validation.
|
|
func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
|
if !strings.Contains(record, "p=") {
|
|
return false
|
|
}
|
|
|
|
// If v= is present, it must be DKIM1
|
|
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
|
|
if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
|
|
return 0
|
|
}
|
|
|
|
hasValid := false
|
|
for _, dkim := range *results.DkimRecords {
|
|
if dkim.Valid {
|
|
hasValid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasValid {
|
|
return 25
|
|
}
|
|
|
|
score = 100
|
|
|
|
// Apply security penalties on the best valid record
|
|
for _, dkim := range *results.DkimRecords {
|
|
if !dkim.Valid {
|
|
continue
|
|
}
|
|
|
|
// SHA-1 signing is deprecated (RFC 8301)
|
|
if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") {
|
|
if score > 60 {
|
|
score = 60
|
|
}
|
|
}
|
|
|
|
// Key size penalties apply only to RSA
|
|
keyType := ""
|
|
if dkim.KeyType != nil {
|
|
keyType = strings.ToLower(*dkim.KeyType)
|
|
}
|
|
if keyType == "rsa" || keyType == "" {
|
|
if dkim.KeySize != nil {
|
|
switch {
|
|
case *dkim.KeySize < 1024:
|
|
if score > 25 {
|
|
score = 25
|
|
}
|
|
case *dkim.KeySize < 2048:
|
|
if score > 75 {
|
|
score = 75
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty.
|
|
}
|
|
|
|
return
|
|
}
|