Skip to content

Commit 5d1dce0

Browse files
committed
WIP add dnssec test
1 parent 6ae2c24 commit 5d1dce0

6 files changed

Lines changed: 235 additions & 2 deletions

File tree

api/openapi.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,10 @@ components:
942942
$ref: '#/components/schemas/DMARCRecord'
943943
bimi_record:
944944
$ref: '#/components/schemas/BIMIRecord'
945+
dnssec_enabled:
946+
type: boolean
947+
description: Whether the From domain has DNSSEC enabled with valid chain of trust
948+
example: true
945949
ptr_records:
946950
type: array
947951
items:

pkg/analyzer/dns.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
127127
// Check BIMI record (for From domain - branding is based on visible sender)
128128
results.BimiRecord = d.checkBIMIRecord(fromDomain, "default")
129129

130+
// Check DNSSEC status (for From domain)
131+
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, fromDomain)
132+
if err == nil {
133+
results.DnssecEnabled = &dnssecEnabled
134+
}
135+
130136
return results
131137
}
132138

@@ -149,6 +155,12 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
149155
// Check BIMI record with default selector
150156
results.BimiRecord = d.checkBIMIRecord(domain, "default")
151157

158+
// Check DNSSEC status
159+
dnssecEnabled, err := d.resolver.IsDNSSECEnabled(nil, domain)
160+
if err == nil {
161+
results.DnssecEnabled = &dnssecEnabled
162+
}
163+
152164
return results
153165
}
154166

@@ -204,11 +216,16 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string
204216

205217
score := 0
206218

219+
// DNSSEC: 10 points
220+
if results.DnssecEnabled != nil && *results.DnssecEnabled {
221+
score += 10
222+
}
223+
207224
// PTR and Forward DNS: 20 points
208225
score += 20 * d.calculatePTRScore(results, senderIP) / 100
209226

210-
// MX Records: 20 points (10 for From domain, 10 for Return-Path domain)
211-
score += 20 * d.calculateMXScore(results) / 100
227+
// MX Records: 10 points (5 for From domain, 5 for Return-Path domain)
228+
score += 10 * d.calculateMXScore(results) / 100
212229

213230
// SPF Records: 20 points
214231
score += 20 * d.calculateSPFScore(results) / 100

pkg/analyzer/dns_resolver.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ type DNSResolver interface {
4848
// LookupHost looks up the given hostname using the local resolver.
4949
// It returns a slice of that host's addresses (IPv4 and IPv6).
5050
LookupHost(ctx context.Context, host string) ([]string, error)
51+
52+
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
53+
// Returns true if the domain has DNSSEC configured and the chain of trust is valid.
54+
IsDNSSECEnabled(ctx context.Context, domain string) (bool, error)
5155
}
5256

5357
// StandardDNSResolver is the default DNS resolver implementation that uses goresolver with DNSSEC validation.
@@ -194,3 +198,40 @@ func (r *StandardDNSResolver) LookupHost(ctx context.Context, host string) ([]st
194198

195199
return allAddrs, nil
196200
}
201+
202+
// IsDNSSECEnabled checks if the given domain has DNSSEC enabled by querying for DNSKEY records.
203+
// It uses DNSSEC validation to ensure the chain of trust is valid.
204+
// Returns true if DNSSEC is properly configured and validated, false otherwise.
205+
func (r *StandardDNSResolver) IsDNSSECEnabled(ctx context.Context, domain string) (bool, error) {
206+
// Ensure the domain ends with a dot for DNS queries
207+
queryName := domain
208+
if !strings.HasSuffix(queryName, ".") {
209+
queryName = queryName + "."
210+
}
211+
212+
// Query for DNSKEY records with DNSSEC validation
213+
// If this succeeds, it means:
214+
// 1. The domain has DNSKEY records (DNSSEC is configured)
215+
// 2. The DNSSEC chain of trust is valid (validated by StrictNSQuery)
216+
rrs, err := r.resolver.StrictNSQuery(queryName, dns.TypeDNSKEY)
217+
if err != nil {
218+
// DNSSEC is not enabled or validation failed
219+
return false, nil
220+
}
221+
222+
// Check if we got any DNSKEY records
223+
if len(rrs) == 0 {
224+
return false, nil
225+
}
226+
227+
// Verify we actually have DNSKEY records (not just any RR type)
228+
hasDNSKEY := false
229+
for _, rr := range rrs {
230+
if _, ok := rr.(*dns.DNSKEY); ok {
231+
hasDNSKEY = true
232+
break
233+
}
234+
}
235+
236+
return hasDNSKEY, nil
237+
}

pkg/analyzer/dns_resolver_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// This file is part of the happyDeliver (R) project.
2+
// Copyright (c) 2025 happyDomain
3+
// Authors: Pierre-Olivier Mercier, et al.
4+
//
5+
// This program is offered under a commercial and under the AGPL license.
6+
// For commercial licensing, contact us at <contact@happydomain.org>.
7+
//
8+
// For AGPL licensing:
9+
// This program is free software: you can redistribute it and/or modify
10+
// it under the terms of the GNU Affero General Public License as published by
11+
// the Free Software Foundation, either version 3 of the License, or
12+
// (at your option) any later version.
13+
//
14+
// This program is distributed in the hope that it will be useful,
15+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
// GNU Affero General Public License for more details.
18+
//
19+
// You should have received a copy of the GNU Affero General Public License
20+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
21+
22+
package analyzer
23+
24+
import (
25+
"context"
26+
"testing"
27+
)
28+
29+
func TestIsDNSSECEnabled(t *testing.T) {
30+
resolver := NewStandardDNSResolver()
31+
ctx := context.Background()
32+
33+
tests := []struct {
34+
name string
35+
domain string
36+
expectDNSSEC bool
37+
}{
38+
{
39+
name: "ietf.org has DNSSEC",
40+
domain: "ietf.org",
41+
expectDNSSEC: true,
42+
},
43+
{
44+
name: "google.com doesn't have DNSSEC",
45+
domain: "google.com",
46+
expectDNSSEC: false,
47+
},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
enabled, err := resolver.IsDNSSECEnabled(ctx, tt.domain)
53+
if err != nil {
54+
t.Errorf("IsDNSSECEnabled() error = %v", err)
55+
return
56+
}
57+
58+
if enabled != tt.expectDNSSEC {
59+
t.Errorf("IsDNSSECEnabled() for %s = %v, want %v", tt.domain, enabled, tt.expectDNSSEC)
60+
} else {
61+
// Log the result even if we're not validating
62+
if enabled {
63+
t.Logf("%s: DNSSEC is enabled ✅", tt.domain)
64+
} else {
65+
t.Logf("%s: DNSSEC is NOT enabled ⚠️", tt.domain)
66+
}
67+
}
68+
})
69+
}
70+
}
71+
72+
func TestIsDNSSECEnabled_NonExistentDomain(t *testing.T) {
73+
resolver := NewStandardDNSResolver()
74+
ctx := context.Background()
75+
76+
// Test with a domain that doesn't exist
77+
enabled, err := resolver.IsDNSSECEnabled(ctx, "this-domain-definitely-does-not-exist-12345.com")
78+
if err != nil {
79+
// Error is acceptable for non-existent domains
80+
t.Logf("Non-existent domain returned error (expected): %v", err)
81+
return
82+
}
83+
84+
// If no error, DNSSEC should be disabled
85+
if enabled {
86+
t.Error("IsDNSSECEnabled() for non-existent domain should return false")
87+
}
88+
}
89+
90+
func TestIsDNSSECEnabled_WithTrailingDot(t *testing.T) {
91+
resolver := NewStandardDNSResolver()
92+
ctx := context.Background()
93+
94+
// Test that both formats work
95+
domain1 := "cloudflare.com"
96+
domain2 := "cloudflare.com."
97+
98+
enabled1, err1 := resolver.IsDNSSECEnabled(ctx, domain1)
99+
if err1 != nil {
100+
t.Errorf("IsDNSSECEnabled() without trailing dot error = %v", err1)
101+
}
102+
103+
enabled2, err2 := resolver.IsDNSSECEnabled(ctx, domain2)
104+
if err2 != nil {
105+
t.Errorf("IsDNSSECEnabled() with trailing dot error = %v", err2)
106+
}
107+
108+
if enabled1 != enabled2 {
109+
t.Errorf("IsDNSSECEnabled() results differ: without dot = %v, with dot = %v", enabled1, enabled2)
110+
}
111+
}

web/src/lib/components/DnsRecordsCard.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import BimiRecordDisplay from "./BimiRecordDisplay.svelte";
1111
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
1212
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
13+
import DnssecDisplay from "./DnssecDisplay.svelte";
1314
1415
interface Props {
1516
domainAlignment?: DomainAlignment;
@@ -150,6 +151,9 @@
150151

151152
<!-- BIMI Record -->
152153
<BimiRecordDisplay bimiRecord={dnsResults.bimi_record} />
154+
155+
<!-- DNSSEC -->
156+
<DnssecDisplay dnssecEnabled={dnsResults.dnssec_enabled} domain={dnsResults.from_domain} />
153157
{/if}
154158
</div>
155159
</div>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script lang="ts">
2+
interface Props {
3+
dnssecEnabled?: boolean;
4+
domain?: string;
5+
}
6+
7+
let { dnssecEnabled, domain }: Props = $props();
8+
9+
// DNSSEC is valid if it's explicitly enabled
10+
const dnssecIsValid = $derived(dnssecEnabled === true);
11+
</script>
12+
13+
{#if dnssecEnabled !== undefined}
14+
<div class="card mb-4" id="dns-dnssec">
15+
<div class="card-header d-flex justify-content-between align-items-center">
16+
<h5 class="text-muted mb-0">
17+
<i
18+
class="bi"
19+
class:bi-shield-check={dnssecIsValid}
20+
class:text-success={dnssecIsValid}
21+
class:bi-shield-x={!dnssecIsValid}
22+
class:text-warning={!dnssecIsValid}
23+
></i>
24+
DNSSEC
25+
</h5>
26+
<span class="badge bg-secondary">Security</span>
27+
</div>
28+
<div class="card-body">
29+
<p class="card-text small text-muted mb-3">
30+
DNSSEC (DNS Security Extensions) adds cryptographic signatures to DNS records to verify
31+
their authenticity and integrity. It protects against DNS spoofing and cache poisoning
32+
attacks, ensuring that DNS responses haven't been tampered with.
33+
</p>
34+
{#if domain}
35+
<div class="mb-2">
36+
<strong>Domain:</strong> <code>{domain}</code>
37+
</div>
38+
{/if}
39+
{#if dnssecIsValid}
40+
<div class="alert alert-success mb-0">
41+
<i class="bi bi-check-circle me-1"></i>
42+
<strong>Enabled:</strong> DNSSEC is properly configured with a valid chain of trust.
43+
This provides additional security and authenticity for your domain's DNS records.
44+
</div>
45+
{:else}
46+
<div class="alert alert-warning mb-0">
47+
<i class="bi bi-info-circle me-1"></i>
48+
<strong>Not Enabled:</strong> DNSSEC is not configured for this domain. While not
49+
required for email delivery, enabling DNSSEC provides additional security by protecting
50+
against DNS-based attacks. Consider enabling DNSSEC through your domain registrar or
51+
DNS provider.
52+
</div>
53+
{/if}
54+
</div>
55+
</div>
56+
{/if}

0 commit comments

Comments
 (0)