Skip to content

Commit d75b7ee

Browse files
authored
Merge pull request #159 from tianyuan129/main
Introduce DNS Challenge
2 parents ed6bc29 + f22cd1b commit d75b7ee

File tree

4 files changed

+461
-3
lines changed

4 files changed

+461
-3
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package ndncert
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
7+
"github.com/named-data/ndnd/std/types/optional"
8+
)
9+
10+
const (
11+
KwRecordName = "record-name"
12+
KwExpectedValue = "expected-value"
13+
14+
DNSPrefix = "_ndncert-challenge"
15+
)
16+
17+
// ChallengeDns implements the DNS-01 challenge following Let's Encrypt practices.
18+
// The challenge allows certificate requesters to prove domain ownership by creating
19+
// a DNS TXT record containing a challenge token.
20+
//
21+
// Challenge Flow:
22+
// 1. Requester provides domain name they want to validate
23+
// 2. CA generates challenge token and responds with DNS record details
24+
// 3. Requester creates TXT record at _ndncert-challenge.<domain> with challenge response
25+
// 4. Requester confirms record is in place
26+
// 5. CA performs DNS lookup to verify the TXT record exists
27+
type ChallengeDns struct {
28+
// DomainCallback is called to get the domain name from the user.
29+
// It receives the challenge status for user prompting.
30+
DomainCallback func(status string) string
31+
32+
// ConfirmationCallback is called to get confirmation from user that
33+
// they have created the required DNS record.
34+
// It receives the record details and status for user prompting.
35+
ConfirmationCallback func(recordName, expectedValue, status string) string
36+
37+
// internal state for multi-step challenge
38+
domain string
39+
recordName string
40+
expectedValue string
41+
}
42+
43+
func (*ChallengeDns) Name() string {
44+
return KwDns
45+
}
46+
47+
func (c *ChallengeDns) Request(input ParamMap, status optional.Optional[string]) (ParamMap, error) {
48+
// Validate challenge configuration
49+
if c.DomainCallback == nil || c.ConfirmationCallback == nil {
50+
return nil, fmt.Errorf("dns challenge not configured")
51+
}
52+
53+
statusStr := status.GetOr("")
54+
55+
// Initial request: get domain from user
56+
if input == nil {
57+
c.domain = c.DomainCallback(statusStr)
58+
if c.domain == "" {
59+
return nil, fmt.Errorf("no domain provided")
60+
}
61+
62+
if !isValidDomainName(c.domain) {
63+
return nil, fmt.Errorf("invalid domain name: %s", c.domain)
64+
}
65+
66+
return ParamMap{
67+
KwDomain: []byte(c.domain),
68+
}, nil
69+
}
70+
71+
// Handle different challenge statuses
72+
switch statusStr {
73+
case "need-record":
74+
// Extract DNS record information from input parameters
75+
if recordNameBytes, ok := input[KwRecordName]; ok {
76+
c.recordName = string(recordNameBytes)
77+
}
78+
if expectedValueBytes, ok := input[KwExpectedValue]; ok {
79+
c.expectedValue = string(expectedValueBytes)
80+
}
81+
82+
// Get confirmation from user that they've created the DNS record
83+
confirmation := c.ConfirmationCallback(c.recordName, c.expectedValue, statusStr)
84+
if confirmation != "ready" {
85+
return nil, fmt.Errorf("expected 'ready' confirmation, got: %s", confirmation)
86+
}
87+
88+
return ParamMap{
89+
KwConfirmation: []byte("ready"),
90+
}, nil
91+
92+
case "wrong-record":
93+
// DNS verification failed, ask user to retry
94+
confirmation := c.ConfirmationCallback(c.recordName, c.expectedValue, statusStr)
95+
if confirmation != "ready" {
96+
return nil, fmt.Errorf("expected 'ready' confirmation, got: %s", confirmation)
97+
}
98+
99+
return ParamMap{
100+
KwConfirmation: []byte("ready"),
101+
}, nil
102+
103+
case "ready-for-validation":
104+
// Automatic validation phase - no user input needed
105+
return ParamMap{
106+
"verify": []byte("now"),
107+
}, nil
108+
109+
default:
110+
return nil, fmt.Errorf("unknown DNS challenge status: %s", statusStr)
111+
}
112+
}
113+
114+
// isValidDomainName validates domain name format according to RFC 1123
115+
func isValidDomainName(domain string) bool {
116+
if len(domain) == 0 || len(domain) > 253 {
117+
return false
118+
}
119+
120+
// RFC 1123 compliant hostname pattern
121+
domainPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
122+
return domainPattern.MatchString(domain)
123+
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package ndncert_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/named-data/ndnd/std/security/ndncert"
7+
"github.com/named-data/ndnd/std/types/optional"
8+
)
9+
10+
func TestChallengeDns_Name(t *testing.T) {
11+
challenge := &ndncert.ChallengeDns{}
12+
if challenge.Name() != "dns" {
13+
t.Errorf("Expected challenge name 'dns', got '%s'", challenge.Name())
14+
}
15+
}
16+
17+
func TestChallengeDns_InitialRequest(t *testing.T) {
18+
domainCalled := false
19+
expectedDomain := "example.com"
20+
21+
challenge := &ndncert.ChallengeDns{
22+
DomainCallback: func(status string) string {
23+
domainCalled = true
24+
return expectedDomain
25+
},
26+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
27+
return "ready"
28+
},
29+
}
30+
31+
params, err := challenge.Request(nil, optional.Optional[string]{})
32+
if err != nil {
33+
t.Fatalf("Expected no error, got: %v", err)
34+
}
35+
36+
if !domainCalled {
37+
t.Error("Expected domain callback to be called")
38+
}
39+
40+
if string(params["domain"]) != expectedDomain {
41+
t.Errorf("Expected domain parameter '%s', got '%s'", expectedDomain, string(params["domain"]))
42+
}
43+
}
44+
45+
func TestChallengeDns_InvalidDomain(t *testing.T) {
46+
challenge := &ndncert.ChallengeDns{
47+
DomainCallback: func(status string) string {
48+
return "invalid..domain" // Invalid domain format
49+
},
50+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
51+
return "ready"
52+
},
53+
}
54+
55+
_, err := challenge.Request(nil, optional.Optional[string]{})
56+
if err == nil {
57+
t.Error("Expected error for invalid domain, got none")
58+
}
59+
}
60+
61+
func TestChallengeDns_NeedRecordStatus(t *testing.T) {
62+
confirmationCalled := false
63+
expectedRecordName := "_ndncert-challenge.example.com"
64+
expectedValue := "test-token-hash"
65+
66+
challenge := &ndncert.ChallengeDns{
67+
DomainCallback: func(status string) string {
68+
return "example.com"
69+
},
70+
ConfirmationCallback: func(recordName, expValue, status string) string {
71+
confirmationCalled = true
72+
if recordName != expectedRecordName {
73+
t.Errorf("Expected record name '%s', got '%s'", expectedRecordName, recordName)
74+
}
75+
if expValue != expectedValue {
76+
t.Errorf("Expected value '%s', got '%s'", expectedValue, expValue)
77+
}
78+
if status != "need-record" {
79+
t.Errorf("Expected status 'need-record', got '%s'", status)
80+
}
81+
return "ready"
82+
},
83+
}
84+
85+
// Simulate input parameters from CA response
86+
input := ndncert.ParamMap{
87+
"record-name": []byte(expectedRecordName),
88+
"expected-value": []byte(expectedValue),
89+
}
90+
91+
status := optional.Some("need-record")
92+
params, err := challenge.Request(input, status)
93+
if err != nil {
94+
t.Fatalf("Expected no error, got: %v", err)
95+
}
96+
97+
if !confirmationCalled {
98+
t.Error("Expected confirmation callback to be called")
99+
}
100+
101+
if string(params["confirmation"]) != "ready" {
102+
t.Errorf("Expected confirmation parameter 'ready', got '%s'", string(params["confirmation"]))
103+
}
104+
}
105+
106+
func TestChallengeDns_WrongRecordStatus(t *testing.T) {
107+
challenge := &ndncert.ChallengeDns{
108+
DomainCallback: func(status string) string {
109+
return "example.com"
110+
},
111+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
112+
if status != "wrong-record" {
113+
t.Errorf("Expected status 'wrong-record', got '%s'", status)
114+
}
115+
return "ready"
116+
},
117+
}
118+
119+
input := ndncert.ParamMap{
120+
"record-name": []byte("_ndncert-challenge.example.com"),
121+
"expected-value": []byte("test-token-hash"),
122+
}
123+
124+
status := optional.Some("wrong-record")
125+
params, err := challenge.Request(input, status)
126+
if err != nil {
127+
t.Fatalf("Expected no error, got: %v", err)
128+
}
129+
130+
if string(params["confirmation"]) != "ready" {
131+
t.Errorf("Expected confirmation parameter 'ready', got '%s'", string(params["confirmation"]))
132+
}
133+
}
134+
135+
func TestChallengeDns_ReadyForValidationStatus(t *testing.T) {
136+
challenge := &ndncert.ChallengeDns{
137+
DomainCallback: func(status string) string {
138+
return "example.com"
139+
},
140+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
141+
return "ready"
142+
},
143+
}
144+
145+
input := ndncert.ParamMap{}
146+
status := optional.Some("ready-for-validation")
147+
params, err := challenge.Request(input, status)
148+
if err != nil {
149+
t.Fatalf("Expected no error, got: %v", err)
150+
}
151+
152+
if string(params["verify"]) != "now" {
153+
t.Errorf("Expected verify parameter 'now', got '%s'", string(params["verify"]))
154+
}
155+
}
156+
157+
func TestChallengeDns_UnknownStatus(t *testing.T) {
158+
challenge := &ndncert.ChallengeDns{
159+
DomainCallback: func(status string) string {
160+
return "example.com"
161+
},
162+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
163+
return "ready"
164+
},
165+
}
166+
167+
input := ndncert.ParamMap{}
168+
status := optional.Some("unknown-status")
169+
_, err := challenge.Request(input, status)
170+
if err == nil {
171+
t.Error("Expected error for unknown status, got none")
172+
}
173+
}
174+
175+
func TestChallengeDns_NotConfigured(t *testing.T) {
176+
// Test with missing domain callback
177+
challenge := &ndncert.ChallengeDns{
178+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
179+
return "ready"
180+
},
181+
}
182+
183+
_, err := challenge.Request(nil, optional.Optional[string]{})
184+
if err == nil {
185+
t.Error("Expected error for missing domain callback, got none")
186+
}
187+
188+
// Test with missing confirmation callback
189+
challenge2 := &ndncert.ChallengeDns{
190+
DomainCallback: func(status string) string {
191+
return "example.com"
192+
},
193+
}
194+
195+
_, err = challenge2.Request(nil, optional.Optional[string]{})
196+
if err == nil {
197+
t.Error("Expected error for missing confirmation callback, got none")
198+
}
199+
}
200+
201+
// Test domain validation helper functions (if we need to expose them for testing)
202+
func TestIsValidDomainName(t *testing.T) {
203+
// Note: This test assumes isValidDomainName is exported
204+
// If not exported, we can test through the challenge Request method
205+
testCases := []struct {
206+
domain string
207+
valid bool
208+
}{
209+
{"example.com", true},
210+
{"sub.example.com", true},
211+
{"test-domain.example.org", true},
212+
{"a.b", true},
213+
{"", false},
214+
{"-example.com", false},
215+
{"example-.com", false},
216+
{"example..com", false},
217+
{".example.com", false},
218+
{"example.com.", false},
219+
}
220+
221+
for _, tc := range testCases {
222+
challenge := &ndncert.ChallengeDns{
223+
DomainCallback: func(status string) string {
224+
return tc.domain
225+
},
226+
ConfirmationCallback: func(recordName, expectedValue, status string) string {
227+
return "ready"
228+
},
229+
}
230+
231+
_, err := challenge.Request(nil, optional.Optional[string]{})
232+
233+
if tc.valid && err != nil {
234+
t.Errorf("Expected domain '%s' to be valid, got error: %v", tc.domain, err)
235+
}
236+
if !tc.valid && err == nil {
237+
t.Errorf("Expected domain '%s' to be invalid, got no error", tc.domain)
238+
}
239+
}
240+
}

std/security/ndncert/constants.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
const KwEmail = "email"
1111
const KwPin = "pin"
1212
const KwCode = "code"
13+
const KwDns = "dns"
14+
const KwDomain = "domain"
15+
const KwConfirmation = "confirmation"
1316

1417
// Challenge Errors
1518
var ErrChallengeBefore = errors.New("challenge before request")

0 commit comments

Comments
 (0)