# ADR-005: Email-Based Authentication for v1.0.0 Date: 2025-11-20 ## Status Accepted ## 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 use email-based verification as the PRIMARY authentication method, with DNS TXT record verification as an OPTIONAL fast-path.** ### Implementation Approach **Two-Tier Verification**: 1. **DNS TXT Record (Preferred, Optional)**: - Check for `_gondulf.{domain}` TXT record = `verified` - If found: Skip email verification, use cached result - If not found: Fall back to email verification - Result cached in database for future use 2. **Email Verification (Required Fallback)**: - User provides email address at their domain - Server generates 6-digit verification code - Server sends code via SMTP - User enters code (15-minute expiration) - Domain marked as verified in database **Why Both?**: - DNS provides fast path for tech-savvy users - Email provides accessible path for all users - DNS requires upfront setup but smoother repeat authentication - Email requires no setup but requires email access each time ### Rationale **Meets User Requirements**: - Email-based authentication as specified - No external identity providers (GitHub, GitLab) in v1.0.0 - Simple to understand and implement - Familiar UX pattern **Simplicity**: - Email verification is well-understood - Standard library SMTP support (smtplib) - No OAuth 2.0 client implementation needed - No external API dependencies **Security Sufficient for MVP**: - Email access typically indicates domain control - 6-digit codes provide 1,000,000 combinations - 15-minute expiration limits brute-force window - Rate limiting prevents abuse - TLS for email delivery (STARTTLS) **Operational Simplicity**: - Requires only SMTP configuration (widely available) - No API keys or provider accounts needed - No rate limits from external providers - Full control over verification flow **DNS TXT as Enhancement**: - Provides better UX for repeat authentication - Demonstrates domain control more directly - Optional (users not forced to configure DNS) - Cached result eliminates email requirement ## Consequences ### Positive Consequences 1. **User Simplicity**: - Familiar email verification pattern - No need to create accounts on external services - Works with any email provider 2. **Implementation Simplicity**: - Standard library support (smtplib, email) - No external API integration - Straightforward testing (mock SMTP) 3. **Operational Simplicity**: - Single external dependency (SMTP server) - No API rate limits to manage - No provider outages to worry about - Admin controls email templates 4. **Privacy**: - Email addresses NOT stored (deleted after verification) - No data shared with third parties - No tracking by external providers 5. **Flexibility**: - DNS TXT provides fast-path for power users - Email fallback ensures accessibility - No user locked out if DNS unavailable ### Negative Consequences 1. **Email Dependency**: - Requires functioning SMTP configuration - Email delivery not guaranteed (spam filters) - Users must have email access during authentication - Email account compromise = domain compromise 2. **User Experience**: - Extra step vs. provider OAuth (more clicks) - Requires checking email inbox - Potential delay (email delivery time) - Code expiration can frustrate users 3. **Security Limitations**: - Email interception risk (mitigated by TLS) - Email account compromise risk (user responsibility) - Weaker than hardware-based auth (WebAuthn) 4. **Scalability Concerns**: - Email delivery at scale (future concern) - SMTP rate limits (future concern) - Email provider blocking (spam prevention) ### Mitigation Strategies **Email Delivery Reliability**: ```python # Robust SMTP configuration SMTP_CONFIG = { 'host': os.environ['SMTP_HOST'], 'port': int(os.environ.get('SMTP_PORT', '587')), 'use_tls': True, # STARTTLS required 'username': os.environ['SMTP_USERNAME'], 'password': os.environ['SMTP_PASSWORD'], 'from_email': os.environ['SMTP_FROM'], 'timeout': 10, # Fail fast } # Comprehensive error handling try: send_email(to=email, code=code) except SMTPException as e: logger.error(f"Email send failed: {e}") # Display user-friendly error raise HTTPException(500, "Email delivery failed. Try again or contact admin.") ``` **Code Security**: ```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 email # Expiration CODE_LIFETIME = timedelta(minutes=15) # Attempt tracking attempts = code_storage.get_attempts(email) if attempts >= MAX_ATTEMPTS: raise HTTPException(429, "Too many attempts. Try again in 15 minutes.") ``` **Email Interception**: ```python # Require TLS for email delivery smtp.starttls() # Clear warning to users """ We've sent a verification code to your email. Only enter this code if you initiated this login. The code expires in 15 minutes. """ # Log suspicious activity if time_between_send_and_verify < 1_second: logger.warning(f"Suspiciously fast verification: {domain}") ``` **DNS TXT Fast-Path**: ```python # Check DNS first, skip email if verified txt_record = dns.query(f'_gondulf.{domain}', 'TXT') if txt_record == 'verified': logger.info(f"DNS verification successful: {domain}") # Use cached verification, skip email return verified_domain(domain) # Fall back to email logger.info(f"DNS verification not found, using email: {domain}") return email_verification_flow(domain) ``` **User Education**: ```markdown ## Domain Verification Gondulf offers two ways to verify domain ownership: ### Option 1: DNS TXT Record (Recommended) Add this DNS record to skip email verification: - Type: TXT - Name: _gondulf.example.com - Value: verified Benefits: - Faster authentication (no email required) - Verify once, use forever - More secure (DNS control = domain control) ### Option 2: Email Verification - Enter an email address at your domain - We'll send a 6-digit code - Enter the code to verify Benefits: - No DNS configuration needed - Works immediately - Familiar process ``` ## Implementation ### Email Verification Flow ```python from datetime import datetime, timedelta import secrets import smtplib from email.message import EmailMessage class EmailVerificationService: def __init__(self, smtp_config: dict): self.smtp = smtp_config self.codes = {} # In-memory storage (short-lived) def request_code(self, email: str, domain: str) -> None: """ Generate and send verification code. Raises: ValueError: If email domain doesn't match requested domain HTTPException: If rate limit exceeded or email send fails """ # Validate email matches domain email_domain = email.split('@')[1].lower() if email_domain != domain.lower(): raise ValueError(f"Email must be at {domain}") # Check rate limit if self._is_rate_limited(email): raise HTTPException(429, "Too many requests. Try again in 1 hour.") # 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 try: self._send_code_email(email, code) logger.info(f"Verification code sent to {email[:3]}***@{email_domain}") except Exception as e: logger.error(f"Failed to send email to {email_domain}: {e}") raise HTTPException(500, "Email delivery failed") def verify_code(self, email: str, submitted_code: str) -> str: """ Verify submitted code. Returns: domain if valid Raises: HTTPException if invalid/expired """ code_data = self.codes.get(email) if not code_data: raise HTTPException(400, "No verification code found") # Check expiration if datetime.utcnow() > code_data['expires_at']: del self.codes[email] raise HTTPException(400, "Code expired. Request a new one.") # Check attempts code_data['attempts'] += 1 if code_data['attempts'] > 3: del self.codes[email] raise HTTPException(429, "Too many attempts") # Verify code (constant-time comparison) if not secrets.compare_digest(submitted_code, code_data['code']): raise HTTPException(400, "Invalid code") # Success: Clean up and return domain domain = code_data['domain'] del self.codes[email] # Single-use code logger.info(f"Domain verified via email: {domain}") return domain def _send_code_email(self, to: str, code: str) -> None: """Send verification code via SMTP.""" msg = EmailMessage() msg['From'] = self.smtp['from_email'] msg['To'] = to 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) def _is_rate_limited(self, email: str) -> bool: """Check if email is rate limited.""" # Simple in-memory tracking (for v1.0.0) # Future: Redis-based rate limiting recent_codes = [ code for code in self.codes.values() if code.get('email') == email and datetime.utcnow() - code['created_at'] < timedelta(hours=1) ] return len(recent_codes) >= 3 ``` ### DNS TXT Record Verification ```python import dns.resolver class DNSVerificationService: def __init__(self, cache_storage): self.cache = cache_storage def verify_domain(self, domain: str) -> bool: """ Check if domain has valid DNS TXT record. Returns: True if verified, False otherwise """ # Check cache first cached = self.cache.get(domain) if cached and cached['verified']: logger.info(f"Using cached DNS verification: {domain}") return True # Query DNS try: verified = self._query_txt_record(domain) # Cache result self.cache.set(domain, { 'verified': verified, 'verified_at': datetime.utcnow(), 'method': 'txt_record' }) return verified except Exception as e: logger.warning(f"DNS verification failed for {domain}: {e}") return False def _query_txt_record(self, domain: str) -> bool: """ Query _gondulf.{domain} TXT record. Returns: True if record exists with value 'verified' """ record_name = f'_gondulf.{domain}' # Use multiple resolvers for redundancy resolvers = ['8.8.8.8', '1.1.1.1'] for resolver_ip in resolvers: try: resolver = dns.resolver.Resolver() resolver.nameservers = [resolver_ip] resolver.timeout = 5 resolver.lifetime = 5 answers = resolver.resolve(record_name, 'TXT') for rdata in answers: txt_value = rdata.to_text().strip('"') if txt_value == 'verified': logger.info(f"DNS TXT verified: {domain} (resolver: {resolver_ip})") return True except Exception as e: logger.debug(f"DNS query failed (resolver {resolver_ip}): {e}") continue return False ``` ## 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 - 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 - WebAuthn (W3C): https://www.w3.org/TR/webauthn/ (future) ## Decision History - 2025-11-20: Proposed (Architect) - 2025-11-20: Accepted (Architect) - TBD: Review after v1.0.0 deployment (gather user feedback)