Skip to content

Commit a2cfa95

Browse files
committed
Release 0.1.1: Kerberos SASL and reliability fixes
Bump version to 0.1.1 and add end-to-end Kerberos SASL GSSAPI support plus reliability and UX fixes. Key changes: - Implement embedded Kerberos flow (Apache Kerby): build AP-REQ/AP-REP, request service tickets, SPN candidate discovery and AD-compatible KrbConfig. New classes: KerberosMutualAuthState, GssWrapToken (+ RFC1964 and CFX variants), and helpers in GssTokenCodec. - Complete mutual AP-REP verification and RFC4752 security-layer negotiation (no-security-layer response). Prefer AP-REP subkey when available. - DNS: prefer system resolver for A/AAAA lookups, prefer reverse PTR via system before MiniDNS, bound SRV timeouts and smarter SPN ranking to avoid public resolver REFUSED delays. - SessionManager: fix keep-alive/reconnect bug by running silent reconnect on a detached job, preserve lastConnected snapshot, avoid cancelled reconnect leaving UI stuck. - Logging: add AppLogger.debugEnabled toggle, wire settings toggle and persist it in SettingsDataStore/SettingsRepository; settings UI and strings updated. - LdapClient: handle SASL bind continuation (SASL_BIND_IN_PROGRESS) including AP-REP verify, empty second bind, and final security-layer wrap exchange. - UI: diagnostics/reports screens refactored to LazyColumn and various minor UX guards; connection/profile view improvements to avoid duplicate connects. - Tests: add RFC1964 wrap round-trip unit tests and additional GSS token tests. - Docs/Changelog updated and build versionCode/versionName bumped. Also include numerous debug/log messages and error mappings to help troubleshooting.
1 parent 5aa4ebd commit a2cfa95

31 files changed

Lines changed: 1776 additions & 240 deletions

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/).
66

77
## [Unreleased]
88

9+
## [0.1.1] — 2026-08-14
10+
11+
Kerberos SASL bind against Active Directory is now usable end-to-end, with faster connect and a session keep-alive fix after screen lock.
12+
13+
### Fixed
14+
15+
- Complete GSSAPI SASL security-layer negotiation after mutual AP-REP (RFC 1964 / RC4-HMAC Wrap checksum, little-endian usage salt).
16+
- Prefer AP-REP subkey then ticket session key when unwrapping the server Wrap offer.
17+
- Silent LDAP reconnect after keep-alive failure no longer cancels itself mid-connect (dashboard stuck on “Connecting…” after unlock).
18+
- Skip public DNS SRV/A discovery when a Kerberos service principal is already configured (avoids multi-second REFUSED delays on phones using public resolvers).
19+
20+
### Changed
21+
22+
- A/AAAA lookups prefer the Android/system resolver (VPN / WireGuard DNS) instead of MiniDNS public resolvers.
23+
- Bound SRV discovery timeouts when SPN auto-discovery is still needed.
24+
- Guard against duplicate connect attempts after insecure-trust confirmation.
25+
26+
### Added
27+
28+
- Unit tests for RFC 1964 Wrap token round-trip (`GssWrapTokenRfc1964Test`).
29+
930
## [0.1.0] — 2026-08-14
1031

1132
First public application snapshot after the Android Studio scaffold. Early development — not a production AD release.

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ LDAPADvisor Android is a native Android application for:
1515
- Active Directory administrators
1616
- LDAP administrators
1717

18-
Current builds (`0.1.0`) include working LDAP/LDAPS/StartTLS connectivity, AD discovery, directory browsing, selected AD administration actions, diagnostics, advisor findings, and report export. Treat results carefully: the project is still early, and some flows remain limited or unsupported (see [docs/implementation-status.md](docs/implementation-status.md) and [docs/limitations.md](docs/limitations.md)).
18+
Current builds (`0.1.1`) include working LDAP/LDAPS/StartTLS connectivity, embedded Kerberos SASL GSSAPI bind against AD, AD discovery, directory browsing, selected AD administration actions, diagnostics, advisor findings, and report export. Treat results carefully: the project is still early, and some flows remain limited or unsupported (see [docs/implementation-status.md](docs/implementation-status.md) and [docs/limitations.md](docs/limitations.md)).
1919

2020
## Planned Features
2121

@@ -49,7 +49,7 @@ Object creation UI (create user/group/OU) is **limited** (message screens only)
4949

5050
- Android 7.0 (API 24) or later
5151
- Native Kotlin application
52-
- `compileSdk` / `targetSdk` 37 (`versionName` `0.1.0`)
52+
- `compileSdk` / `targetSdk` 37 (`versionName` `0.1.1`)
5353

5454
## Technology
5555

@@ -72,7 +72,7 @@ Currently used in this repository:
7272

7373
## Project Status
7474

75-
**Early development** (`0.1.0`)
75+
**Early development** (`0.1.1`)
7676

7777
Core LDAP/AD, diagnostics, advisor, and reporting paths are implemented in-tree. No stable production release is announced. See [docs/implementation-status.md](docs/implementation-status.md).
7878

‎app/build.gradle.kts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId = "com.jbsan.ldapadvisor"
1616
minSdk = 24
1717
targetSdk = 37
18-
versionCode = 1
19-
versionName = "0.1.0"
18+
versionCode = 2
19+
versionName = "0.1.1"
2020

2121
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2222
}

‎app/src/main/java/com/jbsan/ldapadvisor/app/AppContainer.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.jbsan.ldapadvisor.data.diagnostics.LdapDiagnosticService
1414
import com.jbsan.ldapadvisor.data.diagnostics.TcpDiagnosticService
1515
import com.jbsan.ldapadvisor.data.dns.AdDiscoveryService
1616
import com.jbsan.ldapadvisor.data.dns.DnsResolver
17+
import com.jbsan.ldapadvisor.data.kerberos.KerberosTicketService
1718
import com.jbsan.ldapadvisor.data.ldap.LdapClientFactory
1819
import com.jbsan.ldapadvisor.data.ldap.SessionManager
1920
import com.jbsan.ldapadvisor.data.report.ReportGenerator
@@ -47,14 +48,15 @@ class AppContainer(context: Context) {
4748
customCaDao = database.customCaDao(),
4849
logger = logger,
4950
)
51+
val dnsResolver = DnsResolver()
5052
val sessionManager = SessionManager(
5153
clientFactory = ldapClientFactory,
5254
profileRepository = profileRepository,
5355
secretStore = secretStore,
5456
logger = logger,
57+
kerberosTicketService = KerberosTicketService(logger, dnsResolver),
5558
)
5659

57-
val dnsResolver = DnsResolver()
5860
val adDiscoveryService = AdDiscoveryService(dnsResolver)
5961
val tlsDiagnosticService = TlsDiagnosticService(sslSocketFactoryFactory)
6062
val tcpDiagnosticService = TcpDiagnosticService()

‎app/src/main/java/com/jbsan/ldapadvisor/app/LdapAdvisorApp.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ class LdapAdvisorApp : Application() {
1717
super.onCreate()
1818
container = AppContainer(this)
1919
container.networkMonitor.start()
20+
appScope.launch {
21+
container.settingsRepository.settings.collectLatest { settings ->
22+
container.logger.debugEnabled = settings.debugLoggingEnabled
23+
}
24+
}
2025
appScope.launch {
2126
container.networkMonitor.networkAvailable.collectLatest { available ->
2227
container.sessionManager.onNetworkAvailabilityChanged(available)

‎app/src/main/java/com/jbsan/ldapadvisor/core/logging/AppLogger.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ data class LogEvent(
1717
)
1818

1919
interface AppLogger {
20+
/** When false, [LogLevel.DEBUG] events are dropped (not stored, not mirrored to logcat). */
21+
var debugEnabled: Boolean
22+
2023
fun log(
2124
level: LogLevel,
2225
component: String,

‎app/src/main/java/com/jbsan/ldapadvisor/core/logging/InMemoryAppLogger.kt‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.jbsan.ldapadvisor.core.logging
22

33
import android.util.Log
44
import java.util.ArrayDeque
5+
import java.util.concurrent.atomic.AtomicBoolean
56
import java.util.concurrent.atomic.AtomicInteger
67

78
class InMemoryAppLogger(
@@ -13,6 +14,19 @@ class InMemoryAppLogger(
1314
private val lock = Any()
1415
private val events = ArrayDeque<LogEvent>(maxEvents)
1516
private val sequence = AtomicInteger(0)
17+
private val debugFlag = AtomicBoolean(false)
18+
19+
override var debugEnabled: Boolean
20+
get() = debugFlag.get()
21+
set(value) {
22+
val previous = debugFlag.getAndSet(value)
23+
if (previous == value) return
24+
if (value) {
25+
emit(LogLevel.INFO, "AppLogger", "Debug logging enabled", null, null, null)
26+
} else {
27+
emit(LogLevel.INFO, "AppLogger", "Debug logging disabled", null, null, null)
28+
}
29+
}
1630

1731
override fun log(
1832
level: LogLevel,
@@ -21,6 +35,18 @@ class InMemoryAppLogger(
2135
errorCode: String?,
2236
durationMs: Long?,
2337
throwable: Throwable?,
38+
) {
39+
if (level == LogLevel.DEBUG && !debugEnabled) return
40+
emit(level, component, message, errorCode, durationMs, throwable)
41+
}
42+
43+
private fun emit(
44+
level: LogLevel,
45+
component: String,
46+
message: String,
47+
errorCode: String?,
48+
durationMs: Long?,
49+
throwable: Throwable?,
2450
) {
2551
val sanitizedMessage = LogSanitizer.sanitize(message)
2652
val sanitizedDetails = throwable?.let {

‎app/src/main/java/com/jbsan/ldapadvisor/data/datastore/SettingsDataStore.kt‎

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,35 +21,12 @@ class SettingsDataStore(context: Context) {
2121
private val dataStore = context.applicationContext.settingsDataStore
2222

2323
val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
24-
AppSettings(
25-
themeMode = prefs[Keys.THEME]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
26-
?: ThemeMode.SYSTEM,
27-
readOnlyByDefault = prefs[Keys.READ_ONLY_BY_DEFAULT] ?: true,
28-
defaultConnectTimeoutMs = prefs[Keys.CONNECT_TIMEOUT] ?: 5_000,
29-
defaultReadTimeoutMs = prefs[Keys.READ_TIMEOUT] ?: 10_000,
30-
diagnosticConcurrency = prefs[Keys.DIAG_CONCURRENCY] ?: 4,
31-
diagnosticTcpTimeoutMs = prefs[Keys.DIAG_TCP_TIMEOUT] ?: 3_000,
32-
reportSanitizationDefault = prefs[Keys.REPORT_SANITIZE] ?: true,
33-
historyRetentionDays = prefs[Keys.HISTORY_RETENTION] ?: 30,
34-
saveSearchHistory = prefs[Keys.SAVE_SEARCH_HISTORY] ?: true,
35-
)
24+
prefs.toAppSettings()
3625
}
3726

3827
suspend fun update(transform: (AppSettings) -> AppSettings) {
3928
dataStore.edit { prefs ->
40-
val current = AppSettings(
41-
themeMode = prefs[Keys.THEME]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
42-
?: ThemeMode.SYSTEM,
43-
readOnlyByDefault = prefs[Keys.READ_ONLY_BY_DEFAULT] ?: true,
44-
defaultConnectTimeoutMs = prefs[Keys.CONNECT_TIMEOUT] ?: 5_000,
45-
defaultReadTimeoutMs = prefs[Keys.READ_TIMEOUT] ?: 10_000,
46-
diagnosticConcurrency = prefs[Keys.DIAG_CONCURRENCY] ?: 4,
47-
diagnosticTcpTimeoutMs = prefs[Keys.DIAG_TCP_TIMEOUT] ?: 3_000,
48-
reportSanitizationDefault = prefs[Keys.REPORT_SANITIZE] ?: true,
49-
historyRetentionDays = prefs[Keys.HISTORY_RETENTION] ?: 30,
50-
saveSearchHistory = prefs[Keys.SAVE_SEARCH_HISTORY] ?: true,
51-
)
52-
val next = transform(current)
29+
val next = transform(prefs.toAppSettings())
5330
prefs[Keys.THEME] = next.themeMode.name
5431
prefs[Keys.READ_ONLY_BY_DEFAULT] = next.readOnlyByDefault
5532
prefs[Keys.CONNECT_TIMEOUT] = next.defaultConnectTimeoutMs
@@ -59,9 +36,24 @@ class SettingsDataStore(context: Context) {
5936
prefs[Keys.REPORT_SANITIZE] = next.reportSanitizationDefault
6037
prefs[Keys.HISTORY_RETENTION] = next.historyRetentionDays
6138
prefs[Keys.SAVE_SEARCH_HISTORY] = next.saveSearchHistory
39+
prefs[Keys.DEBUG_LOGGING] = next.debugLoggingEnabled
6240
}
6341
}
6442

43+
private fun Preferences.toAppSettings(): AppSettings = AppSettings(
44+
themeMode = this[Keys.THEME]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
45+
?: ThemeMode.SYSTEM,
46+
readOnlyByDefault = this[Keys.READ_ONLY_BY_DEFAULT] ?: true,
47+
defaultConnectTimeoutMs = this[Keys.CONNECT_TIMEOUT] ?: 5_000,
48+
defaultReadTimeoutMs = this[Keys.READ_TIMEOUT] ?: 10_000,
49+
diagnosticConcurrency = this[Keys.DIAG_CONCURRENCY] ?: 4,
50+
diagnosticTcpTimeoutMs = this[Keys.DIAG_TCP_TIMEOUT] ?: 3_000,
51+
reportSanitizationDefault = this[Keys.REPORT_SANITIZE] ?: true,
52+
historyRetentionDays = this[Keys.HISTORY_RETENTION] ?: 30,
53+
saveSearchHistory = this[Keys.SAVE_SEARCH_HISTORY] ?: true,
54+
debugLoggingEnabled = this[Keys.DEBUG_LOGGING] ?: false,
55+
)
56+
6557
private object Keys {
6658
val THEME = stringPreferencesKey("theme_mode")
6759
val READ_ONLY_BY_DEFAULT = booleanPreferencesKey("read_only_by_default")
@@ -72,5 +64,6 @@ class SettingsDataStore(context: Context) {
7264
val REPORT_SANITIZE = booleanPreferencesKey("report_sanitization_default")
7365
val HISTORY_RETENTION = intPreferencesKey("history_retention_days")
7466
val SAVE_SEARCH_HISTORY = booleanPreferencesKey("save_search_history")
67+
val DEBUG_LOGGING = booleanPreferencesKey("debug_logging_enabled")
7568
}
7669
}

‎app/src/main/java/com/jbsan/ldapadvisor/data/dns/DnsResolver.kt‎

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ package com.jbsan.ldapadvisor.data.dns
33
import kotlinx.coroutines.Dispatchers
44
import kotlinx.coroutines.withContext
55
import org.minidns.hla.ResolverApi
6-
import org.minidns.record.A
7-
import org.minidns.record.AAAA
86
import org.minidns.record.PTR
97
import org.minidns.record.SRV
108
import java.net.Inet4Address
@@ -26,35 +24,31 @@ data class DnsLookupResult(
2624
)
2725

2826
/**
29-
* DNS resolver using MiniDNS with the device/system resolver path.
30-
* Does not hardcode Google/Cloudflare resolvers.
27+
* DNS resolver preferring the Android/system resolver (VPN / private DNS / WireGuard)
28+
* for A/AAAA. SRV still uses MiniDNS (system APIs do not expose SRV on all API levels).
3129
*/
3230
class DnsResolver {
3331
private val api: ResolverApi = ResolverApi.INSTANCE
3432

3533
suspend fun resolveA(name: String): Result<List<String>> = withContext(Dispatchers.IO) {
3634
try {
37-
val result = api.resolve(name, A::class.java)
38-
if (!result.wasSuccessful()) {
39-
return@withContext Result.failure(
40-
IllegalStateException(result.responseCode?.name ?: "A lookup failed for $name"),
41-
)
42-
}
43-
Result.success(result.answersOrEmptySet.map { it.inetAddress.hostAddress!! })
35+
Result.success(
36+
InetAddress.getAllByName(name)
37+
.filterIsInstance<Inet4Address>()
38+
.mapNotNull { it.hostAddress },
39+
)
4440
} catch (e: Exception) {
4541
Result.failure(e)
4642
}
4743
}
4844

4945
suspend fun resolveAAAA(name: String): Result<List<String>> = withContext(Dispatchers.IO) {
5046
try {
51-
val result = api.resolve(name, AAAA::class.java)
52-
if (!result.wasSuccessful()) {
53-
return@withContext Result.failure(
54-
IllegalStateException(result.responseCode?.name ?: "AAAA lookup failed for $name"),
55-
)
56-
}
57-
Result.success(result.answersOrEmptySet.map { it.inetAddress.hostAddress!! })
47+
Result.success(
48+
InetAddress.getAllByName(name)
49+
.filterIsInstance<Inet6Address>()
50+
.mapNotNull { it.hostAddress },
51+
)
5852
} catch (e: Exception) {
5953
Result.failure(e)
6054
}
@@ -84,6 +78,11 @@ class DnsResolver {
8478

8579
suspend fun resolvePtr(ip: String): Result<List<String>> = withContext(Dispatchers.IO) {
8680
try {
81+
// Prefer system reverse lookup (honours VPN DNS).
82+
val system = InetAddress.getByName(ip).canonicalHostName?.trim()?.trimEnd('.')
83+
if (!system.isNullOrBlank() && !system.equals(ip, ignoreCase = true) && !looksLikeIp(system)) {
84+
return@withContext Result.success(listOf(system))
85+
}
8786
val arpa = toArpaName(ip)
8887
val result = api.resolve(arpa, PTR::class.java)
8988
if (!result.wasSuccessful()) {
@@ -149,4 +148,13 @@ class DnsResolver {
149148
else -> error("Unsupported address type")
150149
}
151150
}
151+
152+
private fun looksLikeIp(value: String): Boolean {
153+
return try {
154+
val a = InetAddress.getByName(value)
155+
a.hostAddress.equals(value, ignoreCase = true) || value.contains(':')
156+
} catch (_: Exception) {
157+
false
158+
}
159+
}
152160
}

0 commit comments

Comments
 (0)