# 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)**: 1. **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 2. **Email Discovery via rel="me" (Required)**: - Fetch user's domain homepage (e.g., https://example.com) - Parse HTML for `` - 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 3. **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 1. **Enhanced Security**: - Two-factor verification (DNS + Email) - Stronger ownership proof than single factor - Prevents single-point-of-compromise attacks - Aligns with security best practices 2. **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 3. **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 4. **Implementation Simplicity**: - HTML parsing for rel="me" (standard libraries) - DNS queries (dnspython) - SMTP email sending (smtplib) - No external API dependencies 5. **Privacy**: - Email addresses NOT stored after verification - No data shared with third parties - No tracking by external providers - Minimal data collection 6. **Transparency**: - User explicitly declares email on their site - No hidden verification methods - User controls both DNS and HTML - Clear requirements for setup ### Negative Consequences 1. **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 2. **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) 3. **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) 4. **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://) 5. **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**: ```markdown ## 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): 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**: ```python 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**: ```python 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**: ```python # 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: 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): ```python # 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) ```python 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 ' # 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 and ) 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)