Add comprehensive Phase 2 documentation: - Complete design document for two-factor domain verification - Implementation guide with code examples - ADR for implementation decisions (ADR-0004) - ADR for rel="me" email discovery (ADR-008) - Phase 1 impact assessment - All 23 clarification questions answered - Updated architecture docs (indieauth-protocol, security) - Updated ADR-005 with rel="me" approach - Updated backlog with technical debt items Design ready for Phase 2 implementation. Generated with Claude Code https://claude.com/claude-code Co-Authored-By: Claude <noreply@anthropic.com>
22 KiB
ADR-005: Two-Factor Domain Verification for v1.0.0 (DNS + Email via rel="me")
Date: 2025-11-20 Last Updated: 2025-11-20
Status
Accepted (Updated)
Context
Gondulf requires users to prove domain ownership to authenticate. Multiple authentication methods exist for proving domain control.
Authentication Methods Evaluated
1. Email Verification
- User provides email at their domain
- Server sends verification code to email
- User enters code to prove email access
- Assumes: Email access = domain control
2. DNS TXT Record
- Admin adds TXT record to DNS:
_gondulf.example.com=verified - Server queries DNS to verify record
- Assumes: DNS control = domain control
3. External Identity Providers (GitHub, GitLab, etc.)
- User links domain to GitHub/GitLab profile
- Server verifies profile contains domain
- User authenticates via OAuth to provider
- Assumes: Provider verification = domain control
4. WebAuthn / FIDO2
- User registers hardware security key
- Authentication via cryptographic challenge
- Assumes: Physical key possession = domain control (after initial registration)
5. IndieAuth Delegation
- User's domain delegates to another IndieAuth server
- Server follows delegation chain
- Assumes: Delegated server = domain control
User Requirements
From project brief:
- v1.0.0: Email-based ONLY (no other identity providers)
- Simplicity: Keep MVP simple and focused
- Scale: 10s of users initially
- No client registration: Simplify client onboarding
Technical Constraints
SMTP Dependency:
- Requires email server configuration
- Potential delivery failures (spam filters, configuration errors)
- Dependency on external service (email provider)
Security Considerations:
- Email interception risk (transit security)
- Email account compromise risk (user responsibility)
- Code brute-force risk (limited entropy)
User Experience:
- Familiar pattern (like password reset)
- Requires email access during authentication
- Additional step vs. provider OAuth (GitHub, etc.)
Decision
Gondulf v1.0.0 will require BOTH DNS TXT record verification AND email verification using the IndieWeb rel="me" pattern. Both verifications must succeed for authentication to complete.
Implementation Approach
Two-Factor Verification (Both Required):
-
DNS TXT Record Verification (Required):
- Check for
_gondulf.{domain}TXT record =verified - If found: Proceed to email verification
- If not found: Authentication fails with instructions to add TXT record
- Proves: User controls DNS for the domain
- Check for
-
Email Discovery via rel="me" (Required):
- Fetch user's domain homepage (e.g., https://example.com)
- Parse HTML for
<link rel="me" href="mailto:user@example.com"> - Extract email address from rel="me" link
- If not found: Authentication fails with instructions to add rel="me" link
- Proves: User has published email relationship on their site
-
Email Verification Code (Required):
- Server generates 6-digit verification code
- Server sends code to discovered email address via SMTP
- User enters code (15-minute expiration)
- Verification code must be correct to complete authentication
- Proves: User controls the email account
Why All Three?:
- DNS TXT: Proves domain DNS control (strong ownership signal)
- rel="me": Follows IndieWeb standard for identity claims
- Email Code: Proves active control of the email account (not just DNS/HTML)
- Combined: Two-factor verification provides stronger security than either alone
Rationale
Enhanced Security Model:
- Two-factor verification: DNS control + Email control
- Prevents attacks where only one factor is compromised
- DNS TXT proves domain ownership
- Email code proves active account control
- rel="me" follows IndieWeb standards for identity
Follows IndieWeb Standards:
- rel="me" is standard practice for identity claims (see: https://thesatelliteoflove.com)
- Aligns with IndieAuth ecosystem expectations
- Users likely already have rel="me" links for other purposes
- Email discovery is self-documenting (user's site declares their email)
No User-Provided Email Input:
- Server discovers email from user's site (no manual entry)
- Prevents typos and social engineering
- Email is self-attested by user on their own domain
- Reduces attack surface (can't claim arbitrary email)
Stronger Than Single-Factor:
- Attacker needs DNS control AND email access
- Compromised DNS alone: insufficient
- Compromised email alone: insufficient
- Requires control of both infrastructure and communication
Simplicity Maintained:
- Two verification checks, but both straightforward
- DNS TXT: standard practice
- rel="me": standard HTML link
- Email code: familiar pattern
- Total setup time: < 5 minutes for technical users
Consequences
Positive Consequences
-
Enhanced Security:
- Two-factor verification (DNS + Email)
- Stronger ownership proof than single factor
- Prevents single-point-of-compromise attacks
- Aligns with security best practices
-
IndieWeb Standard Compliance:
- Follows rel="me" pattern from IndieWeb community
- Interoperability with other IndieWeb tools
- Users may already have rel="me" configured
- Self-documenting identity claims
-
Reduced Attack Surface:
- No user-provided email input (prevents typos/social engineering)
- Email discovered from user's own site
- Can't claim arbitrary email addresses
- User controls all verification requirements
-
Implementation Simplicity:
- HTML parsing for rel="me" (standard libraries)
- DNS queries (dnspython)
- SMTP email sending (smtplib)
- No external API dependencies
-
Privacy:
- Email addresses NOT stored after verification
- No data shared with third parties
- No tracking by external providers
- Minimal data collection
-
Transparency:
- User explicitly declares email on their site
- No hidden verification methods
- User controls both DNS and HTML
- Clear requirements for setup
Negative Consequences
-
Higher Setup Complexity:
- Users must configure TWO things (DNS TXT + rel="me" link)
- More steps than single-factor approaches
- Requires basic HTML editing skills
- May deter non-technical users
-
Email Dependency:
- Requires functioning SMTP configuration
- Email delivery not guaranteed (spam filters)
- Users must have email access during authentication
- Email account compromise still a risk (mitigated by DNS requirement)
-
User Experience:
- More setup steps vs. simpler alternatives
- Requires checking email inbox during login
- Potential delay (email delivery time)
- Code expiration can frustrate users
- Both verifications must succeed (no fallback)
-
HTML Parsing Complexity:
- Must parse potentially malformed HTML
- Multiple possible HTML formats for rel="me"
- Case sensitivity issues
- Must handle various link formats (mailto: vs https://)
-
Failure Points:
- DNS lookup failure blocks authentication
- Site unavailable blocks authentication
- Email send failure blocks authentication
- No fallback mechanism (both required)
Mitigation Strategies
Clear Setup Instructions:
## Domain Verification Setup
Gondulf requires two verifications to prove domain ownership:
### Step 1: Add DNS TXT Record
Add this DNS record to your domain:
- Type: TXT
- Name: _gondulf.example.com
- Value: verified
This proves you control DNS for your domain.
### Step 2: Add rel="me" Link to Your Homepage
Add this HTML to your homepage (e.g., https://example.com/index.html):
<link rel="me" href="mailto:your-email@example.com">
This declares your email address publicly on your site.
### Step 3: Verify Email Access
During login:
- We'll discover your email from the rel="me" link
- We'll send a verification code to that email
- Enter the code to complete authentication
Setup time: ~5 minutes
Robust HTML Parsing:
from bs4 import BeautifulSoup
from urllib.parse import urlparse
def discover_email_from_site(domain_url: str) -> Optional[str]:
"""
Fetch site and discover email from rel="me" link.
Returns: email address or None if not found
"""
try:
# Fetch homepage
response = requests.get(domain_url, timeout=10, allow_redirects=True)
response.raise_for_status()
# Parse HTML (handle malformed HTML gracefully)
soup = BeautifulSoup(response.content, 'html.parser')
# Find all rel="me" links
me_links = soup.find_all('link', rel='me') + soup.find_all('a', rel='me')
# Look for mailto: links
for link in me_links:
href = link.get('href', '')
if href.startswith('mailto:'):
email = href.replace('mailto:', '').strip()
# Validate email format
if validate_email_format(email):
logger.info(f"Discovered email via rel='me' for {domain_url}")
return email
logger.warning(f"No rel='me' mailto: link found for {domain_url}")
return None
except Exception as e:
logger.error(f"Failed to discover email for {domain_url}: {e}")
return None
DNS Verification:
def verify_dns_txt(domain: str) -> bool:
"""
Verify _gondulf.{domain} TXT record exists.
Returns: True if verified, False otherwise
"""
try:
import dns.resolver
# Query multiple resolvers for redundancy
resolvers = ['8.8.8.8', '1.1.1.1']
verified_count = 0
for resolver_ip in resolvers:
resolver = dns.resolver.Resolver()
resolver.nameservers = [resolver_ip]
resolver.timeout = 5
answers = resolver.resolve(f'_gondulf.{domain}', 'TXT')
for rdata in answers:
if rdata.to_text().strip('"') == 'verified':
verified_count += 1
break
# Require consensus from multiple resolvers
return verified_count >= 2
except Exception as e:
logger.warning(f"DNS verification failed for {domain}: {e}")
return False
Helpful Error Messages:
# DNS TXT not found
if not dns_verified:
return ErrorResponse("""
DNS verification failed.
Please add this TXT record to your domain:
- Type: TXT
- Name: _gondulf.{domain}
- Value: verified
DNS changes may take up to 24 hours to propagate.
""")
# rel="me" not found
if not email_discovered:
return ErrorResponse("""
Could not find rel="me" link on your site.
Please add this to your homepage:
<link rel="me" href="mailto:your-email@example.com">
See: https://indieweb.org/rel-me for more information.
""")
# Email send failure
if not email_sent:
return ErrorResponse("""
Failed to send verification code to {email}.
Please check:
- Email address is correct in your rel="me" link
- Email server is accepting mail
- Check spam/junk folder
""")
Code Security (unchanged):
# Sufficient entropy
code = ''.join(secrets.choice('0123456789') for _ in range(6))
# 1,000,000 possible codes
# Rate limiting
MAX_ATTEMPTS = 3 # Per email
MAX_CODES = 3 # Per hour per domain
# Expiration
CODE_LIFETIME = timedelta(minutes=15)
# Single-use enforcement
code_storage.mark_used(code_id)
Implementation
Complete Authentication Flow (v1.0.0)
from datetime import datetime, timedelta
import secrets
import smtplib
import requests
import dns.resolver
from email.message import EmailMessage
from bs4 import BeautifulSoup
from typing import Optional, Tuple
class DomainVerificationService:
"""
Two-factor domain verification: DNS TXT + Email via rel="me"
"""
def __init__(self, smtp_config: dict):
self.smtp = smtp_config
self.codes = {} # In-memory storage for verification codes
def verify_domain_ownership(self, domain: str) -> Tuple[bool, Optional[str], Optional[str]]:
"""
Perform two-factor domain verification.
Returns: (success, email_discovered, error_message)
Steps:
1. Verify DNS TXT record
2. Discover email from rel="me" link
3. Send verification code to email
4. User enters code (handled separately)
"""
# Step 1: Verify DNS TXT record
dns_verified = self._verify_dns_txt(domain)
if not dns_verified:
return False, None, "DNS TXT record not found. Please add _gondulf.{domain} = verified"
# Step 2: Discover email from site's rel="me" link
email = self._discover_email_from_site(f"https://{domain}")
if not email:
return False, None, 'No rel="me" mailto: link found on homepage. Please add <link rel="me" href="mailto:you@example.com">'
# Step 3: Generate and send verification code
code_sent = self._send_verification_code(email, domain)
if not code_sent:
return False, email, f"Failed to send verification code to {email}"
# Return success with discovered email
return True, email, None
def verify_code(self, email: str, submitted_code: str) -> Tuple[bool, str]:
"""
Verify submitted code.
Returns: (success, domain or error_message)
"""
code_data = self.codes.get(email)
if not code_data:
return False, "No verification code found. Please request a new code."
# Check expiration
if datetime.utcnow() > code_data['expires_at']:
del self.codes[email]
return False, "Code expired. Please request a new code."
# Check attempts
code_data['attempts'] += 1
if code_data['attempts'] > 3:
del self.codes[email]
return False, "Too many attempts. Please restart authentication."
# Verify code (constant-time comparison)
if not secrets.compare_digest(submitted_code, code_data['code']):
return False, "Invalid code. Please try again."
# Success: Clean up and return domain
domain = code_data['domain']
del self.codes[email] # Single-use code
logger.info(f"Domain verified: {domain} (DNS + Email)")
return True, domain
def _verify_dns_txt(self, domain: str) -> bool:
"""
Verify _gondulf.{domain} TXT record exists with value 'verified'.
Returns: True if verified, False otherwise
"""
record_name = f'_gondulf.{domain}'
# Use multiple resolvers for redundancy
resolvers = ['8.8.8.8', '1.1.1.1']
verified_count = 0
for resolver_ip in resolvers:
try:
resolver = dns.resolver.Resolver()
resolver.nameservers = [resolver_ip]
resolver.timeout = 5
answers = resolver.resolve(record_name, 'TXT')
for rdata in answers:
if rdata.to_text().strip('"') == 'verified':
verified_count += 1
break
except Exception as e:
logger.debug(f"DNS query failed (resolver {resolver_ip}): {e}")
continue
# Require consensus from at least 2 resolvers
if verified_count >= 2:
logger.info(f"DNS TXT verified: {domain}")
return True
logger.warning(f"DNS TXT verification failed: {domain}")
return False
def _discover_email_from_site(self, domain_url: str) -> Optional[str]:
"""
Fetch domain homepage and discover email from rel="me" link.
Returns: email address or None if not found
"""
try:
# Fetch homepage
response = requests.get(domain_url, timeout=10, allow_redirects=True)
response.raise_for_status()
# Parse HTML (BeautifulSoup handles malformed HTML)
soup = BeautifulSoup(response.content, 'html.parser')
# Find all rel="me" links (both <link> and <a>)
me_links = soup.find_all('link', rel='me') + soup.find_all('a', rel='me')
# Look for mailto: links
for link in me_links:
href = link.get('href', '')
if href.startswith('mailto:'):
email = href.replace('mailto:', '').strip()
# Basic email validation
if '@' in email and '.' in email.split('@')[1]:
logger.info(f"Discovered email via rel='me': {domain_url}")
return email
logger.warning(f"No rel='me' mailto: link found: {domain_url}")
return None
except Exception as e:
logger.error(f"Failed to discover email for {domain_url}: {e}")
return None
def _send_verification_code(self, email: str, domain: str) -> bool:
"""
Generate and send verification code to email.
Returns: True if sent successfully, False otherwise
"""
# Check rate limit
if self._is_rate_limited(domain):
logger.warning(f"Rate limit exceeded for domain: {domain}")
return False
# Generate 6-digit code
code = ''.join(secrets.choice('0123456789') for _ in range(6))
# Store code with expiration
self.codes[email] = {
'code': code,
'domain': domain,
'created_at': datetime.utcnow(),
'expires_at': datetime.utcnow() + timedelta(minutes=15),
'attempts': 0,
}
# Send email via SMTP
try:
msg = EmailMessage()
msg['From'] = self.smtp['from_email']
msg['To'] = email
msg['Subject'] = 'Gondulf Verification Code'
msg.set_content(f"""
Your Gondulf verification code is:
{code}
This code expires in 15 minutes.
Only enter this code if you initiated this login.
If you did not request this code, ignore this email.
""")
with smtplib.SMTP(self.smtp['host'], self.smtp['port'], timeout=10) as smtp:
smtp.starttls()
smtp.login(self.smtp['username'], self.smtp['password'])
smtp.send_message(msg)
logger.info(f"Verification code sent to {email[:3]}***@{email.split('@')[1]}")
return True
except Exception as e:
logger.error(f"Failed to send email to {email}: {e}")
return False
def _is_rate_limited(self, domain: str) -> bool:
"""
Check if domain is rate limited (max 3 codes per hour).
Returns: True if rate limited, False otherwise
"""
recent_codes = [
code for code in self.codes.values()
if code.get('domain') == domain
and datetime.utcnow() - code['created_at'] < timedelta(hours=1)
]
return len(recent_codes) >= 3
Future Enhancements
v1.1.0+: Additional Authentication Methods
GitHub/GitLab Providers:
- OAuth 2.0 flow with provider
- Verify domain in profile URL
- Link GitHub username to domain
WebAuthn / FIDO2:
- Register hardware security key
- Challenge/response authentication
- Strongest security option
IndieAuth Delegation:
- Follow rel="authorization_endpoint" link
- Delegate to another IndieAuth server
- Support federated authentication
These will be additive (user chooses method), not replacing email.
Alternatives Considered
Alternative 1: External Providers Only (GitHub, GitLab)
Pros:
- No email infrastructure needed
- Established OAuth 2.0 flows
- Users already have accounts
Cons:
- Contradicts user requirement (email-only in v1.0.0)
- Requires external API integration
- Users locked to specific providers
- Privacy concerns (data sharing)
Rejected: Violates user requirements for v1.0.0.
Alternative 2: WebAuthn as Primary Method
Pros:
- Strongest security (hardware keys)
- Phishing-resistant
- No password/email needed
Cons:
- Requires hardware key (barrier to entry)
- Complex implementation (WebAuthn API)
- Browser compatibility issues
- Not suitable for MVP
Rejected: Too complex for MVP, hardware requirement.
Alternative 3: SMS Verification
Pros:
- Familiar pattern
- Fast delivery
Cons:
- Requires phone number (PII collection)
- SMS delivery costs
- Phone number != domain ownership
- SIM swapping attacks
Rejected: Doesn't prove domain ownership, adds PII collection.
Alternative 4: DNS Only (No Email Fallback)
Pros:
- Strongest proof of domain control
- No email infrastructure
- Simple implementation
Cons:
- Requires DNS knowledge
- Barrier to entry for non-technical users
- DNS propagation delays
- No fallback if DNS inaccessible
Rejected: Too restrictive, not accessible enough.
References
- IndieWeb rel="me": https://indieweb.org/rel-me
- Example Implementation: https://thesatelliteoflove.com (Phil Skents' identity page)
- SMTP Protocol (RFC 5321): https://datatracker.ietf.org/doc/html/rfc5321
- Email Security (STARTTLS): https://datatracker.ietf.org/doc/html/rfc3207
- DNS TXT Records (RFC 1035): https://datatracker.ietf.org/doc/html/rfc1035
- HTML Link Relations: https://www.w3.org/TR/html5/links.html#linkTypes
- BeautifulSoup (HTML parsing): https://www.crummy.com/software/BeautifulSoup/
- WebAuthn (W3C): https://www.w3.org/TR/webauthn/ (future)
Decision History
- 2025-11-20: Proposed (Architect) - Email primary, DNS optional
- 2025-11-20: Accepted (Architect) - Email primary, DNS optional
- 2025-11-20: UPDATED (Architect) - BOTH required (DNS + Email via rel="me")
- Changed from single-factor (email OR DNS) to two-factor (email AND DNS)
- Added rel="me" email discovery (IndieWeb standard)
- Removed user-provided email input (security improvement)
- Enhanced security model with dual verification
- TBD: Review after v1.0.0 deployment (gather user feedback)