Files
Gondulf/docs/decisions/ADR-005-email-based-authentication-v1-0-0.md
Phil Skentelbery 6f06aebf40 docs: add Phase 2 domain verification design and clarifications
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>
2025-11-20 13:05:09 -07:00

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):

  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 <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
  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:

## 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

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)