krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

22ecca12b4fe0e0850401754c674632f322d919d

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-22T03:08:09Z

feat(v2.3.0): add ownership intelligence and subdomain discovery

* implement RDAP-based ownership section
* add ownership diffing and change classification
* add passive subdomain discovery via certificate transparency
* highlight interesting subdomains
* introduce Data+ scaffolding for future features
* include ownership and subdomains in export and history
 DomainDig.xcodeproj/project.pbxproj       |   8 +-
 DomainDig/BatchResultsView.swift          |   3 +
 DomainDig/BatchSweepSummaryView.swift     |   5 +-
 DomainDig/ContentView.swift               | 100 +++++++++++
 DomainDig/DataAccessService.swift         |   7 +
 DomainDig/DomainAvailabilityService.swift |  51 +-----
 DomainDig/DomainDiffService.swift         | 104 ++++++++++-
 DomainDig/DomainOwnershipService.swift    |   7 +
 DomainDig/DomainViewModel.swift           | 214 +++++++++++++++++++++-
 DomainDig/HistoryView.swift               |  14 ++
 DomainDig/Models.swift                    |  70 +++++++-
 DomainDig/RDAPService.swift               | 288 ++++++++++++++++++++++++++++++
 DomainDig/SubdomainDiscoveryService.swift | 118 ++++++++++++
 13 files changed, 923 insertions(+), 66 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 8e652f6..6ce65e4 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -267,7 +267,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 15;
+				CURRENT_PROJECT_VERSION = 16;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.2.0;
+				MARKETING_VERSION = 2.3.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 15;
+				CURRENT_PROJECT_VERSION = 16;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.2.0;
+				MARKETING_VERSION = 2.3.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index 03bc7c4..6bf18c0 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -115,6 +115,9 @@ struct BatchResultRowView: View {
             if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
                 return .yellow
             }
+            if result.quickStatus == "Changed" {
+                return .blue
+            }
             return .green
         case .failed:
             return .red
diff --git a/DomainDig/BatchSweepSummaryView.swift b/DomainDig/BatchSweepSummaryView.swift
index fda2bc5..61b3e37 100644
--- a/DomainDig/BatchSweepSummaryView.swift
+++ b/DomainDig/BatchSweepSummaryView.swift
@@ -12,7 +12,10 @@ struct BatchSweepSummaryView: View {
         }
 
         return summary.results.filter {
-            ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed
+            $0.quickStatus == "Changed" ||
+                $0.quickStatus == "High" ||
+                $0.certificateWarningLevel != .none ||
+                $0.status == .failed
         }
     }
 
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 2be81b3..261b7ca 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -60,6 +60,20 @@ struct ContentView: View {
                             }
                         )
                             .padding(.top, 16)
+                        OwnershipSectionView(
+                            rows: viewModel.ownershipRows,
+                            loading: viewModel.ownershipLoading,
+                            error: viewModel.ownershipError,
+                            showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
+                        )
+                        .padding(.top, 16)
+                        SubdomainsSectionView(
+                            rows: viewModel.subdomainRows,
+                            loading: viewModel.subdomainsLoading,
+                            error: viewModel.subdomainsError,
+                            showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
+                        )
+                        .padding(.top, 16)
                         if !viewModel.currentDiffSections.isEmpty {
                             DomainDiffView(
                                 title: "Latest Changes",
@@ -772,6 +786,92 @@ struct DomainSectionView: View {
     }
 }
 
+struct OwnershipSectionView: View {
+    let rows: [InfoRowViewData]
+    let loading: Bool
+    let error: String?
+    let showsHistoryPlaceholder: Bool
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            SectionTitleView(title: "Ownership")
+            CardView(allowsHorizontalScroll: false) {
+                if loading {
+                    ProgressView("Fetching RDAP ownership…")
+                        .appLoadingStyle()
+                } else {
+                    ForEach(rows) { row in
+                        LabeledValueRow(row: row)
+                    }
+                    if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
+                        MessageRowView(text: error, isError: error != "Unavailable")
+                            .padding(.top, 4)
+                    }
+                    if showsHistoryPlaceholder {
+                        MessageRowView(text: "Ownership history (coming soon)", isError: false)
+                            .padding(.top, 4)
+                    }
+                }
+            }
+        }
+    }
+}
+
+struct SubdomainsSectionView: View {
+    let rows: [SubdomainRowViewData]
+    let loading: Bool
+    let error: String?
+    let showsExtendedPlaceholder: Bool
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack {
+                SectionTitleView(title: "Subdomains")
+                Spacer()
+                Text("\(rows.count)")
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+
+            CardView(allowsHorizontalScroll: false) {
+                if loading {
+                    ProgressView("Checking certificate transparency…")
+                        .appLoadingStyle()
+                } else if rows.isEmpty {
+                    MessageRowView(text: error ?? "No passive subdomains found", isError: false)
+                    if showsExtendedPlaceholder {
+                        MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
+                            .padding(.top, 4)
+                    }
+                } else {
+                    ForEach(rows) { row in
+                        HStack(spacing: 8) {
+                            Text(row.hostname)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.primary)
+                                .textSelection(.enabled)
+                            Spacer()
+                            if row.isInteresting {
+                                Text("Interesting")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(.yellow)
+                                    .padding(.horizontal, 8)
+                                    .padding(.vertical, 4)
+                                    .background(Color.yellow.opacity(0.14))
+                                    .clipShape(Capsule())
+                            }
+                        }
+                    }
+                    if showsExtendedPlaceholder {
+                        MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
+                            .padding(.top, 4)
+                    }
+                }
+            }
+        }
+    }
+}
+
 struct DNSSectionView: View {
     let dnssecLabel: String?
     let sections: [DNSRecordSectionViewData]
diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift
new file mode 100644
index 0000000..fed4fde
--- /dev/null
+++ b/DomainDig/DataAccessService.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+enum DataAccessService {
+    static func hasAccess(to capability: DataCapability) -> Bool {
+        false
+    }
+}
diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift
index 7bef586..9a350ef 100644
--- a/DomainDig/DomainAvailabilityService.swift
+++ b/DomainDig/DomainAvailabilityService.swift
@@ -35,32 +35,11 @@ struct DomainAvailabilityService {
     }
 
     private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
-        guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
-            return nil
-        }
-
-        do {
-            var request = URLRequest(url: url, timeoutInterval: 8)
-            request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
-
-            let (data, response) = try await URLSession.shared.data(for: request)
-            guard let httpResponse = response as? HTTPURLResponse else {
-                return nil
-            }
-
-            switch httpResponse.statusCode {
-            case 200:
-                return isValidRDAPDomainResponse(data) ? .registered : nil
-            case 404:
-                debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org")
-                return nil
-            default:
-                return nil
-            }
-        } catch {
-            debugLog("rdap-error", domain: domain, details: error.localizedDescription)
-            return nil
+        let status = await RDAPService.registrationStatus(for: domain)
+        if status == nil {
+            debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org")
         }
+        return status
     }
 
     private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus {
@@ -85,28 +64,6 @@ struct DomainAvailabilityService {
         }
     }
 
-    private static func isValidRDAPDomainResponse(_ data: Data) -> Bool {
-        guard
-            let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
-        else {
-            return false
-        }
-
-        if object["ldhName"] as? String != nil {
-            return true
-        }
-
-        if object["objectClassName"] as? String == "domain" {
-            return true
-        }
-
-        if object["handle"] as? String != nil, object["unicodeName"] as? String != nil {
-            return true
-        }
-
-        return false
-    }
-
     private static func suggestionCandidates(for domain: String, limit: Int) -> [String] {
         let parts = domain.split(separator: ".")
         guard parts.count >= 2 else { return [] }
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
index 667802a..340873d 100644
--- a/DomainDig/DomainDiffService.swift
+++ b/DomainDig/DomainDiffService.swift
@@ -43,11 +43,13 @@ enum DomainDiffService {
         [
             availabilitySection(from: oldSnapshot, to: newSnapshot),
             primaryIPSection(from: oldSnapshot, to: newSnapshot),
+            ownershipSection(from: oldSnapshot, to: newSnapshot),
             dnsSection(from: oldSnapshot, to: newSnapshot),
             redirectSection(from: oldSnapshot, to: newSnapshot),
             tlsSection(from: oldSnapshot, to: newSnapshot),
             httpSection(from: oldSnapshot, to: newSnapshot),
-            emailSection(from: oldSnapshot, to: newSnapshot)
+            emailSection(from: oldSnapshot, to: newSnapshot),
+            subdomainSection(from: oldSnapshot, to: newSnapshot)
         ]
         .filter { !$0.items.isEmpty }
     }
@@ -58,19 +60,16 @@ enum DomainDiffService {
         generatedAt: Date = Date()
     ) -> DomainChangeSummary {
         let sections = diff(from: oldSnapshot, to: newSnapshot)
-        let meaningfulItems = sections
-            .flatMap(\.items)
-            .filter(\.isMeaningful)
         let allChangedItems = sections
             .flatMap(\.items)
             .filter(\.hasChanges)
 
-        let highlights = summaryHighlights(from: meaningfulItems)
-        let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
-        let message = summaryMessage(from: meaningfulItems, highlights: highlights)
+        let highlights = summaryHighlights(from: allChangedItems)
+        let severity = allChangedItems.map(\.severity).max() ?? .low
+        let message = summaryMessage(from: allChangedItems, highlights: highlights)
 
         return DomainChangeSummary(
-            hasChanges: !meaningfulItems.isEmpty,
+            hasChanges: !allChangedItems.isEmpty,
             changedSections: highlights,
             message: message,
             severity: severity,
@@ -152,6 +151,50 @@ enum DomainDiffService {
         return DomainDiffSection(title: "DNS", items: items)
     }
 
+    private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+        DomainDiffSection(
+            title: "Ownership",
+            items: [
+                compare(
+                    label: "Registrar",
+                    oldValue: normalized(oldSnapshot.ownership?.registrar),
+                    newValue: normalized(newSnapshot.ownership?.registrar),
+                    severity: .high
+                ),
+                compare(
+                    label: "Registration Date",
+                    oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate),
+                    newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate),
+                    severity: .low
+                ),
+                compare(
+                    label: "Expiration Date",
+                    oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate),
+                    newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate),
+                    severity: .low
+                ),
+                compare(
+                    label: "Ownership Status",
+                    oldValue: ownershipList(oldSnapshot.ownership?.status),
+                    newValue: ownershipList(newSnapshot.ownership?.status),
+                    severity: .low
+                ),
+                compare(
+                    label: "Nameservers",
+                    oldValue: ownershipList(oldSnapshot.ownership?.nameservers),
+                    newValue: ownershipList(newSnapshot.ownership?.nameservers),
+                    severity: .medium
+                ),
+                compare(
+                    label: "Abuse Contact",
+                    oldValue: normalized(oldSnapshot.ownership?.abuseEmail),
+                    newValue: normalized(newSnapshot.ownership?.abuseEmail),
+                    severity: .low
+                )
+            ].compactMap { $0 }
+        )
+    }
+
     private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
         DomainDiffSection(
             title: "Redirect",
@@ -252,6 +295,20 @@ enum DomainDiffService {
         )
     }
 
+    private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+        DomainDiffSection(
+            title: "Subdomains",
+            items: [
+                compare(
+                    label: "Passive Subdomains",
+                    oldValue: subdomainList(from: oldSnapshot),
+                    newValue: subdomainList(from: newSnapshot),
+                    severity: .low
+                )
+            ].compactMap { $0 }
+        )
+    }
+
     private static func compare(
         label: String,
         oldValue: String?,
@@ -306,6 +363,13 @@ enum DomainDiffService {
         if labels.contains("Redirect Target") {
             highlights.append("Redirect target changed")
         }
+        if labels.contains("Registrar") {
+            highlights.append("Registrar changed")
+        } else if labels.contains("Nameservers") {
+            highlights.append("Nameservers changed")
+        } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") {
+            highlights.append("Ownership metadata changed")
+        }
         if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
            let message = certificateItem.newValue {
             highlights.append(message)
@@ -323,6 +387,9 @@ enum DomainDiffService {
         if labels.contains("Email Security") {
             highlights.append("Email security changed")
         }
+        if labels.contains("Passive Subdomains") {
+            highlights.append("Subdomains changed")
+        }
 
         var deduplicated: [String] = []
         for highlight in highlights where !deduplicated.contains(highlight) {
@@ -380,6 +447,10 @@ enum DomainDiffService {
         return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
     }
 
+    private static func ownershipDateLabel(_ date: Date?) -> String? {
+        date?.formatted(date: .abbreviated, time: .omitted)
+    }
+
     private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
         if let httpStatusCode = snapshot.httpStatusCode {
             return "\(httpStatusCode)"
@@ -423,4 +494,21 @@ enum DomainDiffService {
             .sorted()
         return headers.isEmpty ? nil : headers.joined(separator: "|")
     }
+
+    private static func ownershipList(_ values: [String]?) -> String? {
+        guard let values else { return nil }
+        let normalizedValues = values
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+            .filter { !$0.isEmpty }
+            .sorted()
+        return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",")
+    }
+
+    private static func subdomainList(from snapshot: LookupSnapshot) -> String? {
+        let values = snapshot.subdomains
+            .map(\.hostname)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+            .sorted()
+        return values.isEmpty ? nil : values.joined(separator: ",")
+    }
 }
diff --git a/DomainDig/DomainOwnershipService.swift b/DomainDig/DomainOwnershipService.swift
new file mode 100644
index 0000000..2c7b738
--- /dev/null
+++ b/DomainDig/DomainOwnershipService.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+enum DomainOwnershipService {
+    static func lookup(domain: String) async -> ServiceResult<DomainOwnership> {
+        await RDAPService.ownership(for: domain)
+    }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 031c062..e895ce6 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -72,6 +72,18 @@ struct PortScanRowViewData: Identifiable {
     let durationLabel: String?
 }
 
+struct SubdomainRowViewData: Identifiable {
+    let id: String
+    let hostname: String
+    let isInteresting: Bool
+
+    init(hostname: String, isInteresting: Bool) {
+        self.id = hostname
+        self.hostname = hostname
+        self.isInteresting = isInteresting
+    }
+}
+
 struct DomainSuggestionViewData: Identifiable {
     let id: UUID
     let domain: String
@@ -107,10 +119,14 @@ struct LookupSnapshot {
     let ipGeolocationError: String?
     let emailSecurity: EmailSecurityResult?
     let emailSecurityError: String?
+    let ownership: DomainOwnership?
+    let ownershipError: String?
     let ptrRecord: String?
     let ptrError: String?
     let redirectChain: [RedirectHop]
     let redirectChainError: String?
+    let subdomains: [DiscoveredSubdomain]
+    let subdomainsError: String?
     let portScanResults: [PortScanResult]
     let portScanError: String?
     let changeSummary: DomainChangeSummary?
@@ -147,10 +163,14 @@ extension HistoryEntry {
             ipGeolocationError: ipGeolocationError,
             emailSecurity: emailSecurity,
             emailSecurityError: emailSecurityError,
+            ownership: ownership,
+            ownershipError: ownershipError,
             ptrRecord: ptrRecord,
             ptrError: ptrError,
             redirectChain: redirectChain,
             redirectChainError: redirectChainError,
+            subdomains: subdomains,
+            subdomainsError: subdomainsError,
             portScanResults: portScanResults,
             portScanError: portScanError,
             changeSummary: changeSummary,
@@ -204,6 +224,10 @@ final class DomainViewModel {
     var emailSecurityLoading = false
     var emailSecurityError: String?
 
+    var ownershipResult: DomainOwnership?
+    var ownershipLoading = false
+    var ownershipError: String?
+
     var ptrRecord: String?
     var ptrLoading = false
     var ptrError: String?
@@ -212,6 +236,10 @@ final class DomainViewModel {
     var redirectChainLoading = false
     var redirectChainError: String?
 
+    var subdomains: [DiscoveredSubdomain] = []
+    var subdomainsLoading = false
+    var subdomainsError: String?
+
     var portScanResults: [PortScanResult] = []
     var portScanLoading = false
     var portScanError: String?
@@ -224,6 +252,7 @@ final class DomainViewModel {
     private(set) var lastLookupDurationMs: Int?
     private(set) var currentDiffSections: [DomainDiffSection] = []
     private(set) var currentChangeSummary: DomainChangeSummary?
+    private(set) var ownershipDiff: [DomainDiffItem] = []
     private(set) var refreshingTrackedDomainID: UUID?
     private(set) var rerunNavigationToken = UUID()
     private(set) var batchResults: [BatchLookupResult] = []
@@ -290,8 +319,10 @@ final class DomainViewModel {
             !reachabilityLoading &&
             !ipGeolocationLoading &&
             !emailSecurityLoading &&
+            !ownershipLoading &&
             !ptrLoading &&
             !redirectChainLoading &&
+            !subdomainsLoading &&
             !portScanLoading &&
             !customPortScanLoading
     }
@@ -438,10 +469,14 @@ final class DomainViewModel {
             ipGeolocationError: ipGeolocationError,
             emailSecurity: emailSecurity,
             emailSecurityError: emailSecurityError,
+            ownership: ownershipResult,
+            ownershipError: ownershipError,
             ptrRecord: ptrRecord,
             ptrError: ptrError,
             redirectChain: redirectChain,
             redirectChainError: redirectChainError,
+            subdomains: subdomains,
+            subdomainsError: subdomainsError,
             portScanResults: allPortScanResults,
             portScanError: combinedPortScanError,
             changeSummary: currentChangeSummary,
@@ -489,6 +524,14 @@ final class DomainViewModel {
         Self.emailRows(from: currentSnapshot)
     }
 
+    var ownershipRows: [InfoRowViewData] {
+        Self.ownershipRows(from: currentSnapshot)
+    }
+
+    var subdomainRows: [SubdomainRowViewData] {
+        Self.subdomainRows(from: currentSnapshot)
+    }
+
     var reachabilityRows: [ReachabilityRowViewData] {
         Self.reachabilityRows(from: currentSnapshot)
     }
@@ -643,6 +686,7 @@ final class DomainViewModel {
         lastLookupDurationMs = nil
         currentDiffSections = []
         currentChangeSummary = nil
+        ownershipDiff = []
         refreshingTrackedDomainID = nil
         clearBatchState()
         clearLookupState()
@@ -791,6 +835,8 @@ final class DomainViewModel {
             group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
             group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
             group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
+            group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) }
+            group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) }
         }
 
         await withTaskGroup(of: Void.self) { group in
@@ -946,6 +992,23 @@ final class DomainViewModel {
         emailSecurityLoading = false
     }
 
+    private func runOwnership(domain: String, lookupID: UUID) async {
+        let result = await DomainOwnershipService.lookup(domain: domain)
+        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+        switch result {
+        case let .success(ownership):
+            ownershipResult = ownership
+            ownershipError = nil
+        case let .empty(message):
+            ownershipResult = nil
+            ownershipError = message
+        case let .error(message):
+            ownershipResult = nil
+            ownershipError = message
+        }
+        ownershipLoading = false
+    }
+
     private func runReverseDNS(ip: String, lookupID: UUID) async {
         let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
         guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
@@ -980,6 +1043,23 @@ final class DomainViewModel {
         redirectChainLoading = false
     }
 
+    private func runSubdomains(domain: String, lookupID: UUID) async {
+        let result = await SubdomainDiscoveryService.discover(for: domain)
+        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+        switch result {
+        case let .success(results):
+            subdomains = results
+            subdomainsError = nil
+        case let .empty(message):
+            subdomains = []
+            subdomainsError = message
+        case let .error(message):
+            subdomains = []
+            subdomainsError = message
+        }
+        subdomainsLoading = false
+    }
+
     private func runPortScan(domain: String, lookupID: UUID) async {
         let result = await PortScanService.scanAll(domain: domain)
         switch result {
@@ -1061,7 +1141,9 @@ final class DomainViewModel {
         async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
         async let httpResult = HTTPHeadersService.fetch(domain: domain)
         async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
+        async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
         async let redirectResult = RedirectChainService.trace(domain: domain)
+        async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
         async let portScanResult = PortScanService.scanAll(domain: domain)
 
         let resolvedDNS = await dnsResult
@@ -1070,7 +1152,9 @@ final class DomainViewModel {
         let hsts = await hstsResult
         let http = await httpResult
         let reachability = await reachabilityResult
+        let resolvedOwnership = await ownershipResult
         let redirects = await redirectResult
+        let resolvedSubdomains = await subdomainResult
         let ports = await portScanResult
 
         guard !Task.isCancelled else { return nil }
@@ -1187,6 +1271,17 @@ final class DomainViewModel {
             emailSecurityError = message
         }
 
+        let ownership: DomainOwnership?
+        let ownershipError: String?
+        switch resolvedOwnership {
+        case let .success(result):
+            ownership = result
+            ownershipError = nil
+        case let .empty(message), let .error(message):
+            ownership = nil
+            ownershipError = message
+        }
+
         let ptrRecord: String?
         let ptrError: String?
         switch resolvedPTR {
@@ -1215,6 +1310,17 @@ final class DomainViewModel {
             ipGeolocationError = "No A record available"
         }
 
+        let subdomains: [DiscoveredSubdomain]
+        let subdomainsError: String?
+        switch resolvedSubdomains {
+        case let .success(result):
+            subdomains = result
+            subdomainsError = nil
+        case let .empty(message), let .error(message):
+            subdomains = []
+            subdomainsError = message
+        }
+
         let snapshot = LookupSnapshot(
             historyEntryID: nil,
             domain: domain,
@@ -1243,10 +1349,14 @@ final class DomainViewModel {
             ipGeolocationError: ipGeolocationError,
             emailSecurity: emailSecurity,
             emailSecurityError: emailSecurityError,
+            ownership: ownership,
+            ownershipError: ownershipError,
             ptrRecord: ptrRecord,
             ptrError: ptrError,
             redirectChain: redirectChain,
             redirectChainError: redirectChainError,
+            subdomains: subdomains,
+            subdomainsError: subdomainsError,
             portScanResults: portScanResults,
             portScanError: portScanError,
             changeSummary: nil,
@@ -1303,6 +1413,7 @@ final class DomainViewModel {
         if updateCurrentState {
             currentChangeSummary = changeSummary
             currentDiffSections = diffSections
+            ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
         }
 
         let entry = HistoryEntry(
@@ -1316,8 +1427,10 @@ final class DomainViewModel {
             ipGeolocation: snapshot.ipGeolocation,
             emailSecurity: snapshot.emailSecurity,
             mtaSts: snapshot.emailSecurity?.mtaSts,
+            ownership: snapshot.ownership,
             ptrRecord: snapshot.ptrRecord,
             redirectChain: snapshot.redirectChain,
+            subdomains: snapshot.subdomains,
             portScanResults: snapshot.portScanResults,
             hstsPreloaded: snapshot.hstsPreloaded,
             availabilityResult: snapshot.availabilityResult,
@@ -1336,8 +1449,10 @@ final class DomainViewModel {
             reachabilityError: snapshot.reachabilityError,
             ipGeolocationError: snapshot.ipGeolocationError,
             emailSecurityError: snapshot.emailSecurityError,
+            ownershipError: snapshot.ownershipError,
             ptrError: snapshot.ptrError,
             redirectChainError: snapshot.redirectChainError,
+            subdomainsError: snapshot.subdomainsError,
             portScanError: snapshot.portScanError
         )
 
@@ -1520,6 +1635,7 @@ final class DomainViewModel {
         hasRun = true
         currentDiffSections = []
         currentChangeSummary = nil
+        ownershipDiff = []
         clearLookupState()
         setAllLoadingStates(true)
         customPortScanLoading = false
@@ -1677,8 +1793,8 @@ final class DomainViewModel {
         let summary = BatchSweepSummary(
             source: source,
             totalDomains: batchResults.count,
-            changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count,
-            unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count,
+            changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
+            unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
             warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
             results: batchResults.sorted { lhs, rhs in
                 if lhs.status != rhs.status {
@@ -1754,12 +1870,18 @@ final class DomainViewModel {
         emailSecurity = nil
         emailSecurityError = nil
         emailSecurityLoading = false
+        ownershipResult = nil
+        ownershipError = nil
+        ownershipLoading = false
         ptrRecord = nil
         ptrError = nil
         ptrLoading = false
         redirectChain = []
         redirectChainError = nil
         redirectChainLoading = false
+        subdomains = []
+        subdomainsError = nil
+        subdomainsLoading = false
         portScanResults = []
         portScanError = nil
         portScanLoading = false
@@ -1778,8 +1900,10 @@ final class DomainViewModel {
         reachabilityLoading = loading
         ipGeolocationLoading = loading
         emailSecurityLoading = loading
+        ownershipLoading = loading
         ptrLoading = loading
         redirectChainLoading = loading
+        subdomainsLoading = loading
         portScanLoading = loading
     }
 
@@ -1872,10 +1996,14 @@ final class DomainViewModel {
             ipGeolocationError: nil,
             emailSecurity: nil,
             emailSecurityError: nil,
+            ownership: nil,
+            ownershipError: nil,
             ptrRecord: nil,
             ptrError: nil,
             redirectChain: [],
             redirectChainError: nil,
+            subdomains: [],
+            subdomainsError: nil,
             portScanResults: [],
             portScanError: nil,
             changeSummary: trackedDomain.lastChangeSummary,
@@ -2100,6 +2228,28 @@ final class DomainViewModel {
         ]
     }
 
+    static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
+        let ownership = snapshot.ownership
+
+        return [
+            InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
+            InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
+            InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
+            InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
+            InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
+            InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
+        ]
+    }
+
+    static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
+        snapshot.subdomains.map { subdomain in
+            SubdomainRowViewData(
+                hostname: subdomain.hostname,
+                isInteresting: isInterestingSubdomain(subdomain.hostname)
+            )
+        }
+    }
+
     static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
         snapshot.reachabilityResults.map {
             ReachabilityRowViewData(
@@ -2179,6 +2329,12 @@ final class DomainViewModel {
             "tls_status",
             "http_status_grade",
             "email_security_summary",
+            "registrar",
+            "ownership_expires",
+            "ownership_status",
+            "ownership_nameservers",
+            "subdomain_count",
+            "subdomains",
             "last_updated"
         ]
 
@@ -2191,6 +2347,12 @@ final class DomainViewModel {
                 httpsSummary(from: snapshot),
                 httpStatusGradeSummary(from: snapshot),
                 emailSummary(from: snapshot),
+                snapshot.ownership?.registrar ?? "",
+                snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
+                snapshot.ownership?.status.joined(separator: " | ") ?? "",
+                snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
+                "\(snapshot.subdomains.count)",
+                snapshot.subdomains.map(\.hostname).joined(separator: " | "),
                 csvDateFormatter.string(from: snapshot.timestamp)
             ]
         }
@@ -2282,6 +2444,33 @@ final class DomainViewModel {
             }
         }
 
+        appendSection("Ownership") {
+            for row in ownershipRows(from: snapshot) {
+                lines.append("  \(row.label): \(row.value)")
+            }
+            if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
+                lines.append("  Source: \(ownershipError)")
+            }
+            if !DataAccessService.hasAccess(to: .ownershipHistory) {
+                lines.append("  Ownership history (coming soon)")
+            }
+        }
+
+        appendSection("Subdomains") {
+            lines.append("  Count: \(snapshot.subdomains.count)")
+            if snapshot.subdomains.isEmpty {
+                lines.append("  \(snapshot.subdomainsError ?? "No passive subdomains found")")
+            } else {
+                for subdomain in subdomainRows(from: snapshot) {
+                    let marker = subdomain.isInteresting ? " [interesting]" : ""
+                    lines.append("  \(subdomain.hostname)\(marker)")
+                }
+            }
+            if !DataAccessService.hasAccess(to: .extendedSubdomains) {
+                lines.append("  Extended subdomain discovery (Data+)")
+            }
+        }
+
         appendSection("DNS") {
             if let dnsError = snapshot.dnsError {
                 lines.append("  Error: \(dnsError)")
@@ -2544,6 +2733,13 @@ final class DomainViewModel {
         return formatter
     }()
 
+    private static let ownershipDateFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.dateStyle = .medium
+        formatter.timeStyle = .none
+        return formatter
+    }()
+
     private static let csvDateFormatter: ISO8601DateFormatter = {
         let formatter = ISO8601DateFormatter()
         formatter.formatOptions = [.withInternetDateTime]
@@ -2554,6 +2750,14 @@ final class DomainViewModel {
         let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
         return "\"\(escaped)\""
     }
+
+    private static func isInterestingSubdomain(_ hostname: String) -> Bool {
+        let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
+        let labels = hostname.lowercased().split(separator: ".").map(String.init)
+        return labels.contains { label in
+            keywords.contains(where: { label.contains($0) })
+        }
+    }
 }
 
 private extension String {
@@ -2565,3 +2769,9 @@ private extension String {
         isEmpty ? nil : self
     }
 }
+
+private extension Array where Element == String {
+    var nilIfEmpty: [String]? {
+        isEmpty ? nil : self
+    }
+}
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 35ec723..6f3d1a9 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -147,6 +147,20 @@ struct HistoryDetailView: View {
                     onEditNote: nil
                 )
                     .padding(.top, 16)
+                OwnershipSectionView(
+                    rows: DomainViewModel.ownershipRows(from: snapshot),
+                    loading: false,
+                    error: snapshot.ownershipError,
+                    showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
+                )
+                .padding(.top, 16)
+                SubdomainsSectionView(
+                    rows: DomainViewModel.subdomainRows(from: snapshot),
+                    loading: false,
+                    error: snapshot.subdomainsError,
+                    showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
+                )
+                .padding(.top, 16)
                 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
                     if let changeSummary = entry.changeSummary {
                         DomainChangeSummaryView(summary: changeSummary)
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 2e0a5c2..b1e5432 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -184,6 +184,13 @@ struct BatchSweepSummary: Identifiable, Equatable {
     let generatedAt: Date
 }
 
+enum DataCapability: String, Codable {
+    case ownershipHistory
+    case dnsHistory
+    case extendedSubdomains
+    case domainPricing
+}
+
 enum HistoryDateFilter: String, CaseIterable, Identifiable {
     case today
     case last7Days
@@ -501,6 +508,48 @@ struct IPGeolocation: Codable {
     let longitude: Double?
 }
 
+// MARK: - Ownership Models
+
+struct DomainOwnership: Codable, Equatable {
+    let registrar: String?
+    let createdDate: Date?
+    let expirationDate: Date?
+    let status: [String]
+    let nameservers: [String]
+    let abuseEmail: String?
+
+    init(
+        registrar: String? = nil,
+        createdDate: Date? = nil,
+        expirationDate: Date? = nil,
+        status: [String] = [],
+        nameservers: [String] = [],
+        abuseEmail: String? = nil
+    ) {
+        self.registrar = registrar
+        self.createdDate = createdDate
+        self.expirationDate = expirationDate
+        self.status = status
+        self.nameservers = nameservers
+        self.abuseEmail = abuseEmail
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
+        createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
+        expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
+        status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
+        nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
+        abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
+    }
+}
+
+struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable {
+    var id: String { hostname }
+    let hostname: String
+}
+
 // MARK: - Email Security Models
 
 struct EmailSecurityResult: Codable {
@@ -627,8 +676,10 @@ struct HistoryEntry: Identifiable, Codable {
     let ipGeolocation: IPGeolocation?
     var emailSecurity: EmailSecurityResult?
     var mtaSts: MTASTSResult?
+    var ownership: DomainOwnership?
     var ptrRecord: String?
     var redirectChain: [RedirectHop]
+    var subdomains: [DiscoveredSubdomain]
     var portScanResults: [PortScanResult]
     var hstsPreloaded: Bool?
     var availabilityResult: DomainAvailabilityResult?
@@ -647,23 +698,26 @@ struct HistoryEntry: Identifiable, Codable {
     var reachabilityError: String?
     var ipGeolocationError: String?
     var emailSecurityError: String?
+    var ownershipError: String?
     var ptrError: String?
     var redirectChainError: String?
+    var subdomainsError: String?
     var portScanError: String?
 
     init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
          sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
          reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
-         emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
-         redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
+         emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
+         ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
+         portScanResults: [PortScanResult] = [],
          hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
          suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
          totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
          tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
          changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
          reachabilityError: String? = nil, ipGeolocationError: String? = nil,
-         emailSecurityError: String? = nil, ptrError: String? = nil,
-         redirectChainError: String? = nil, portScanError: String? = nil) {
+         emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil,
+         redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) {
         self.domain = domain
         self.timestamp = timestamp
         self.trackedDomainID = trackedDomainID
@@ -674,8 +728,10 @@ struct HistoryEntry: Identifiable, Codable {
         self.ipGeolocation = ipGeolocation
         self.emailSecurity = emailSecurity
         self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
+        self.ownership = ownership
         self.ptrRecord = ptrRecord
         self.redirectChain = redirectChain
+        self.subdomains = subdomains
         self.portScanResults = portScanResults
         self.hstsPreloaded = hstsPreloaded
         self.availabilityResult = availabilityResult
@@ -694,8 +750,10 @@ struct HistoryEntry: Identifiable, Codable {
         self.reachabilityError = reachabilityError
         self.ipGeolocationError = ipGeolocationError
         self.emailSecurityError = emailSecurityError
+        self.ownershipError = ownershipError
         self.ptrError = ptrError
         self.redirectChainError = redirectChainError
+        self.subdomainsError = subdomainsError
         self.portScanError = portScanError
     }
 
@@ -712,8 +770,10 @@ struct HistoryEntry: Identifiable, Codable {
         ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
         emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
         mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
+        ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
         ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
         redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
+        subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
         portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
         hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
         availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -732,8 +792,10 @@ struct HistoryEntry: Identifiable, Codable {
         reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
         ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
         emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
+        ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
         ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
         redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
+        subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
         portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
     }
 }
diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift
new file mode 100644
index 0000000..c38a140
--- /dev/null
+++ b/DomainDig/RDAPService.swift
@@ -0,0 +1,288 @@
+import Foundation
+
+enum RDAPService {
+    static func registrationStatus(for domain: String) async -> DomainAvailabilityStatus? {
+        let normalizedDomain = normalize(domain)
+        guard !normalizedDomain.isEmpty else { return nil }
+
+        switch await cache.response(for: normalizedDomain) {
+        case let .success(response):
+            return response.isDomainRecord ? .registered : nil
+        case .empty:
+            return nil
+        case .error:
+            return nil
+        }
+    }
+
+    static func ownership(for domain: String) async -> ServiceResult<DomainOwnership> {
+        let normalizedDomain = normalize(domain)
+        guard !normalizedDomain.isEmpty else {
+            return .empty("Unavailable")
+        }
+
+        switch await cache.response(for: normalizedDomain) {
+        case let .success(response):
+            let ownership = DomainOwnership(
+                registrar: response.registrarName,
+                createdDate: response.createdDate,
+                expirationDate: response.expirationDate,
+                status: response.status,
+                nameservers: response.nameservers,
+                abuseEmail: response.abuseEmail
+            )
+            return .success(ownership)
+        case let .empty(message):
+            return .empty(message)
+        case let .error(message):
+            return .error(message)
+        }
+    }
+
+    private static let cache = RDAPCache()
+
+    private static func normalize(_ domain: String) -> String {
+        domain
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .lowercased()
+    }
+}
+
+private actor RDAPCache {
+    private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:]
+    private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:]
+
+    func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
+        if let cachedResponse = cachedResponses[domain] {
+            return cachedResponse
+        }
+
+        if let inFlightTask = inFlightTasks[domain] {
+            return await inFlightTask.value
+        }
+
+        let task = Task<ServiceResult<RDAPDomainResponse>, Never> {
+            await fetchRDAPResponse(for: domain)
+        }
+        inFlightTasks[domain] = task
+
+        let result = await task.value
+        cachedResponses[domain] = result
+        inFlightTasks[domain] = nil
+        return result
+    }
+}
+
+private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
+    guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
+        return .error("Unavailable")
+    }
+
+    do {
+        var request = URLRequest(url: url, timeoutInterval: 8)
+        request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let httpResponse = response as? HTTPURLResponse else {
+            return .error(URLError(.badServerResponse).localizedDescription)
+        }
+
+        switch httpResponse.statusCode {
+        case 200:
+            let decoder = JSONDecoder()
+            let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data)
+            guard rdapResponse.isDomainRecord else {
+                return .empty("Unavailable")
+            }
+            return .success(rdapResponse)
+        case 404:
+            return .empty("Unavailable")
+        default:
+            return .error("Unavailable")
+        }
+    } catch {
+        return .error(error.localizedDescription)
+    }
+}
+
+private struct RDAPDomainResponse: Decodable, Sendable {
+    let ldhName: String?
+    let objectClassName: String?
+    let unicodeName: String?
+    let handle: String?
+    let rawStatus: [String]?
+    let rawNameservers: [RDAPNameserver]?
+    let events: [RDAPEvent]?
+    let entities: [RDAPEntity]?
+
+    enum CodingKeys: String, CodingKey {
+        case ldhName
+        case objectClassName
+        case unicodeName
+        case handle
+        case rawStatus = "status"
+        case rawNameservers = "nameservers"
+        case events
+        case entities
+    }
+
+    var isDomainRecord: Bool {
+        if ldhName?.isEmpty == false {
+            return true
+        }
+        if objectClassName == "domain" {
+            return true
+        }
+        return handle != nil && unicodeName != nil
+    }
+
+    var registrarName: String? {
+        entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName
+    }
+
+    var createdDate: Date? {
+        eventDate(for: ["registration", "registered"])
+    }
+
+    var expirationDate: Date? {
+        eventDate(for: ["expiration", "expiry", "expired"])
+    }
+
+    var abuseEmail: String? {
+        entities?.first(where: { $0.roles.contains("abuse") })?.email
+            ?? entities?.first(where: { $0.roles.contains("registrar") })?.abuseEntity?.email
+    }
+
+    var nameservers: [String] {
+        let rawValues = rawNameservers?.compactMap { $0.ldhName ?? $0.unicodeName } ?? []
+        return deduplicated(rawValues)
+    }
+
+    var status: [String] {
+        deduplicated(rawStatus ?? [])
+    }
+
+    private func eventDate(for actions: [String]) -> Date? {
+        let normalizedActions = Set(actions)
+        return events?
+            .first(where: { normalizedActions.contains($0.eventAction.lowercased()) })?
+            .parsedDate
+    }
+
+    private func deduplicated(_ values: [String]) -> [String] {
+        var seen = Set<String>()
+        return values
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+            .filter { seen.insert($0.lowercased()).inserted }
+    }
+}
+
+private struct RDAPNameserver: Decodable, Sendable {
+    let ldhName: String?
+    let unicodeName: String?
+}
+
+private struct RDAPEvent: Decodable, Sendable {
+    let eventAction: String
+    let eventDate: String
+
+    var parsedDate: Date? {
+        RDAPDateParser.parse(eventDate)
+    }
+}
+
+private struct RDAPEntity: Decodable, Sendable {
+    let roles: [String]
+    let vcardArray: RDAPVCardArray?
+    let entities: [RDAPEntity]?
+
+    var bestDisplayName: String? {
+        vcardArray?.fullName ?? vcardArray?.organization ?? vcardArray?.email
+    }
+
+    var email: String? {
+        vcardArray?.email
+    }
+
+    var abuseEntity: RDAPEntity? {
+        entities?.first(where: { $0.roles.contains("abuse") })
+    }
+}
+
+private struct RDAPVCardArray: Decodable, Sendable {
+    let values: [[RDAPJSONValue]]
+
+    init(from decoder: Decoder) throws {
+        var container = try decoder.unkeyedContainer()
+        _ = try container.decode(String.self)
+        values = try container.decode([[RDAPJSONValue]].self)
+    }
+
+    var fullName: String? {
+        value(for: "fn")
+    }
+
+    var organization: String? {
+        value(for: "org")
+    }
+
+    var email: String? {
+        value(for: "email")
+    }
+
+    private func value(for key: String) -> String? {
+        values.first(where: { $0.first?.stringValue?.lowercased() == key })?.last?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+}
+
+private enum RDAPJSONValue: Decodable, Sendable {
+    case string(String)
+    case bool(Bool)
+    case number(Double)
+    case null
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+        if let value = try? container.decode(String.self) {
+            self = .string(value)
+        } else if let value = try? container.decode(Bool.self) {
+            self = .bool(value)
+        } else if let value = try? container.decode(Double.self) {
+            self = .number(value)
+        } else {
+            self = .null
+        }
+    }
+
+    var stringValue: String? {
+        switch self {
+        case let .string(value):
+            return value
+        case let .bool(value):
+            return value ? "true" : "false"
+        case let .number(value):
+            return String(value)
+        case .null:
+            return nil
+        }
+    }
+}
+
+private enum RDAPDateParser {
+    private static let iso8601WithFractional: ISO8601DateFormatter = {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        return formatter
+    }()
+
+    private static let iso8601: ISO8601DateFormatter = {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+        return formatter
+    }()
+
+    static func parse(_ value: String) -> Date? {
+        iso8601WithFractional.date(from: value) ?? iso8601.date(from: value)
+    }
+}
diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift
new file mode 100644
index 0000000..57db25d
--- /dev/null
+++ b/DomainDig/SubdomainDiscoveryService.swift
@@ -0,0 +1,118 @@
+import Foundation
+
+enum SubdomainDiscoveryService {
+    static func discover(for domain: String, limit: Int = 25) async -> ServiceResult<[DiscoveredSubdomain]> {
+        let normalizedDomain = normalize(domain)
+        guard !normalizedDomain.isEmpty else {
+            return .empty("No passive subdomains found")
+        }
+
+        return await cache.subdomains(for: normalizedDomain, limit: limit)
+    }
+
+    private static let cache = SubdomainDiscoveryCache()
+
+    private static func normalize(_ domain: String) -> String {
+        domain
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .lowercased()
+    }
+}
+
+private actor SubdomainDiscoveryCache {
+    private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:]
+    private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:]
+    private var lastRequestAt: Date?
+
+    func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
+        if let cachedResult = cachedResults[domain] {
+            return cachedResult
+        }
+
+        if let inFlightTask = inFlightTasks[domain] {
+            return await inFlightTask.value
+        }
+
+        let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> {
+            await enforceRateLimit()
+            return await fetchSubdomains(for: domain, limit: limit)
+        }
+        inFlightTasks[domain] = task
+
+        let result = await task.value
+        cachedResults[domain] = result
+        inFlightTasks[domain] = nil
+        return result
+    }
+
+    private func enforceRateLimit() async {
+        if let lastRequestAt {
+            let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt))
+            if delay > 0 {
+                try? await Task.sleep(for: .seconds(delay))
+            }
+        }
+        lastRequestAt = Date()
+    }
+
+    private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
+        var components = URLComponents(string: "https://crt.sh/")!
+        components.queryItems = [
+            URLQueryItem(name: "q", value: "%.\(domain)"),
+            URLQueryItem(name: "output", value: "json")
+        ]
+
+        guard let url = components.url else {
+            return .error("Subdomain discovery unavailable")
+        }
+
+        do {
+            let request = URLRequest(url: url, timeoutInterval: 10)
+            let (data, response) = try await URLSession.shared.data(for: request)
+            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+                return .error("Subdomain discovery unavailable")
+            }
+
+            let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
+            let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
+            return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
+        } catch {
+            return .error(error.localizedDescription)
+        }
+    }
+
+    private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
+        var seen = Set<String>()
+        var results: [DiscoveredSubdomain] = []
+
+        for entry in entries {
+            let names = entry.nameValue
+                .split(separator: "\n")
+                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+
+            for name in names {
+                let sanitized = name.hasPrefix("*.") ? String(name.dropFirst(2)) : name
+                guard sanitized != domain, sanitized.hasSuffix(".\(domain)") else {
+                    continue
+                }
+                guard seen.insert(sanitized).inserted else {
+                    continue
+                }
+                results.append(DiscoveredSubdomain(hostname: sanitized))
+                if results.count == limit {
+                    return results
+                }
+            }
+        }
+
+        return results
+    }
+}
+
+private struct CRTShEntry: Decodable {
+    let nameValue: String
+
+    enum CodingKeys: String, CodingKey {
+        case nameValue = "name_value"
+    }
+}