From af2248e46746d4436ec48c6e6d949878cf599493 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 01:05:33 +0200 Subject: [PATCH 1/8] feat(desktop): honor operating-system trusted certificate authorities Desktop Nextcloud clients previously trusted only the bundled Java runtime roots, so a server using a private CA trusted by the operating system could not sign in. Nextcloud-bound desktop OkHttp clients now share one trust manager that accepts a chain when either the bundled roots or the operating-system anchors validate it completely. Linux reads the first complete system CA bundle; Windows reads the Windows-ROOT store through SunMSCAPI, whose runtime module is now added to Windows packages. Unreadable, oversized, truncated, or malformed bundles add no anchors. Hostname verification, JSSE PKIX checks, and update-download trust are unchanged. Advances #471 --- PLATFORMS.md | 22 ++ .../471-desktop-system-trust-store.md | 7 + docs/README.md | 3 +- docs/login-diagnostics.md | 40 +++ ui/build.gradle.kts | 4 + .../app/DesktopFileSyncRemoteTree.kt | 2 +- .../app/DesktopNextcloudServices.kt | 2 +- .../app/DesktopSystemTrustAnchors.kt | 229 ++++++++++++++++++ .../nextcloudnative/app/DesktopTlsTrust.kt | 156 ++++++++++++ .../app/PlatformAudioPlayback.desktop.kt | 2 +- .../app/DesktopSystemTrustAnchorsTest.kt | 204 ++++++++++++++++ .../app/DesktopTlsTrustTest.kt | 167 +++++++++++++ .../content/guides/desktop-getting-started.md | 2 + 13 files changed, 836 insertions(+), 4 deletions(-) create mode 100644 changes/unreleased/471-desktop-system-trust-store.md create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt diff --git a/PLATFORMS.md b/PLATFORMS.md index 2aa943643..0333c3bd7 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -342,6 +342,28 @@ published installer. retained-data and support guidance, without a reset or an update claim. A valid legacy session can still repair malformed registry data. +### Desktop TLS trust + +**Last reviewed: 2026-10-08.** These are source and deterministic-test +guarantees, not claims about a published installer; the code may have changed. + +- Desktop clients that contact the Nextcloud server use one process-wide trust + manager from `DesktopTlsTrust.kt`. It accepts a chain when either the bundled + Java runtime roots or the operating-system anchors validate it completely. + OkHttp hostname verification is unchanged, and no trust-all path exists. +- `DesktopSystemTrustAnchors.kt` reads the first complete Linux system CA + bundle or the Windows `Windows-ROOT` store through SunMSCAPI. Windows + packages add the `jdk.crypto.mscapi` runtime module for this. macOS reads no + system anchors yet. +- An unreadable, oversized, truncated, or malformed bundle contributes no + anchors. Update checks and project news keep the bundled roots only. +- `DesktopTlsTrustTest` and `DesktopSystemTrustAnchorsTest` cover synthetic CAs, + untrusted and expired chains, hostname mismatch, and malformed or missing + bundles. A Windows test host also loads its real ROOT store. No test + establishes behavior inside a Flatpak or other sandbox. +- User-facing guidance is in + [sign-in diagnostics](docs/login-diagnostics.md#server-certificates-and-private-certificate-authorities). + ## Platform delivery rule Shared code is valuable only when it preserves correct native behavior. Move a diff --git a/changes/unreleased/471-desktop-system-trust-store.md b/changes/unreleased/471-desktop-system-trust-store.md new file mode 100644 index 000000000..0d8774cac --- /dev/null +++ b/changes/unreleased/471-desktop-system-trust-store.md @@ -0,0 +1,7 @@ +category: feature +issue: 471 +pull: none +platforms: linux, windows +user-facing: yes + +Linux and Windows desktop now also trust certificate authorities from the operating system trust store, so servers using a private CA can sign in. Chain and hostname checks are unchanged. diff --git a/docs/README.md b/docs/README.md index 41770ed16..6ca8bb57a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,8 @@ is the source of truth for maintained public documents. ## Contributing, testing and security - [Sign-in recovery and login diagnostics](login-diagnostics.md): browser approval, - connection retries, cancellation, and saving a diagnostics report before sign-in. + connection retries, server certificate trust, cancellation, and saving a + diagnostics report before sign-in. - [Contributing](../CONTRIBUTING.md): quick start, running the apps and tests, troubleshooting, test-account safety, pull requests, and authorship. - [Nextcloud compatibility instance](../integration/nextcloud-demo/README.md): diff --git a/docs/login-diagnostics.md b/docs/login-diagnostics.md index ca98132fd..9f56f4e4d 100644 --- a/docs/login-diagnostics.md +++ b/docs/login-diagnostics.md @@ -31,6 +31,46 @@ The app retries this kind of failure after a bounded delay. When the server answers again, the browser-approval status returns. You can also cancel, change the address or connection, and start a fresh attempt. +## Server certificates and private certificate authorities + +**Last reviewed: 2026-10-08.** This describes repository source and +deterministic tests, which may have changed. Check the +[desktop trust source](https://github.com/obiente/native/blob/main/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt) +and the release notes for the installed build; a published package may predate +this behavior. + +The app never offers a switch that disables certificate validation. A server +certificate must chain to a trusted certificate authority (CA) and match the +server name. + +On Linux and Windows desktop, connections to the Nextcloud server trust the +CAs bundled with the app's Java runtime and, in addition, the CAs the operating +system trusts: + +- **Linux** reads the first complete system CA bundle among + `/etc/ssl/certs/ca-certificates.crt`, `/etc/pki/tls/certs/ca-bundle.crt`, + `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, `/etc/ssl/ca-bundle.pem`, + and `/etc/ssl/cert.pem`. Add a private CA with the distribution's tool, such + as `update-ca-certificates` (Debian and Ubuntu), `trust anchor` (Arch Linux), + or `update-ca-trust` (Fedora), then restart nati.ve. If `curl` verifies the + server without extra options, the bundle normally contains the CA. +- **Windows** reads the current user's view of the Trusted Root Certification + Authorities store, which normally includes machine-wide roots and roots the + user installed. Restart nati.ve after adding a root. +- **macOS** desktop does not yet read the Keychain; it uses only the bundled + Java roots. + +The system CAs are read once when the app starts. An unreadable, oversized, +truncated, or malformed bundle adds no CAs; the bundled roots still apply. +Inside a sandbox such as Flatpak, the app sees only the CA bundle that the +sandbox exposes at those paths, which might not include CAs added on the host. +Release update checks and downloads, and project news, keep using only the +bundled roots. + +Android honors CAs that the device owner installed and can trust one reviewed +self-signed certificate for one server address. Desktop does not offer that +per-server certificate review. + ## Time limit and uncertain results Each attempt has a five-minute deadline, including network waits. diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index fc9aab651..246553358 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -230,6 +230,7 @@ kotlin { val desktopTest by getting desktopTest.dependencies { implementation("com.squareup.okhttp3:mockwebserver3:5.3.0") + implementation("com.squareup.okhttp3:okhttp-tls:5.4.0") } } } @@ -256,6 +257,9 @@ compose.desktop { nativeDistributions { modules("jdk.security.auth") + // SunMSCAPI exposes the Windows ROOT store so OS-trusted CAs are honored for TLS. + // The module exists only in Windows JDKs, and jpackage builds each OS natively. + if (System.getProperty("os.name").startsWith("Windows")) modules("jdk.crypto.mscapi") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) // Preserve Windows/Linux launcher paths used by existing integrations. packageName = if (System.getProperty("os.name").startsWith("Mac")) "nati.ve" else "NextcloudNative" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt index fae9c0a3d..f6667164f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt @@ -846,7 +846,7 @@ private fun java.io.InputStream.copyBoundedTo(output: java.io.OutputStream, maxi private fun okhttp3.Response.requireAccepted(accepted: Boolean, operation: String): Unit = if (accepted) Unit else throw DesktopFileSyncHttpStatusException(code, operation) -private fun desktopFileSyncHttpClient(): OkHttpClient = OkHttpClient.Builder() +private fun desktopFileSyncHttpClient(): OkHttpClient = OkHttpClient.Builder().useDesktopSystemTrust() .followRedirects(false) .followSslRedirects(false) .readTimeout(FILE_SYNC_NETWORK_INACTIVITY_MINUTES, TimeUnit.MINUTES) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 50184f8d4..e7e306505 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -701,7 +701,7 @@ class DesktopNextcloudServices( preferences = preferences.node("app-updates-v1"), onInstallerConfirmationOpened = { target -> onDesktopUpdateInstallerOpened(target.platform) }, ) - private val httpClient = OkHttpClient.Builder().trackJvmNetworkFailures().build() + private val httpClient = OkHttpClient.Builder().useDesktopSystemTrust().trackJvmNetworkFailures().build() private val supportIntake = JvmSupportIntake( diagnostics = supportDiagnostics, temporaryRoot = resolvedSupportIntakeRoot, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt new file mode 100644 index 000000000..a9aa1edac --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt @@ -0,0 +1,229 @@ +package dev.obiente.nextcloudnative.app + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 + +/** Operating-system trust source used in addition to the bundled JVM roots. */ +internal enum class DesktopSystemTrustSource { + LinuxCaBundle, + WindowsRootStore, +} + +/** Bounded, non-secret reason why no operating-system anchors were added. */ +internal enum class DesktopSystemTrustUnavailableReason { + UnsupportedPlatform, + NotFound, + Unreadable, + Malformed, + TooLarge, + Empty, + StoreUnavailable, +} + +internal sealed interface DesktopSystemTrustAnchors { + data class Loaded( + val source: DesktopSystemTrustSource, + val certificates: List, + ) : DesktopSystemTrustAnchors { + init { + require(certificates.isNotEmpty()) + } + } + + data class Unavailable(val reason: DesktopSystemTrustUnavailableReason) : DesktopSystemTrustAnchors +} + +/** + * System CA bundles maintained by the distribution trust tooling (`update-ca-certificates`, + * `update-ca-trust`, or `trust extract-compat`). Administrator-added CAs appear here after that + * tooling runs, which is the same trust that curl and OpenSSL use by default. + */ +internal val LINUX_SYSTEM_CA_BUNDLE_PATHS: List = listOf( + // Debian, Ubuntu, Arch Linux, Gentoo, NixOS, and freedesktop Flatpak runtimes. + "/etc/ssl/certs/ca-certificates.crt", + // Fedora and RHEL. + "/etc/pki/tls/certs/ca-bundle.crt", + // RHEL and CentOS 7 or later through p11-kit extraction. + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + // openSUSE. + "/etc/ssl/ca-bundle.pem", + // Alpine and other musl-based distributions. + "/etc/ssl/cert.pem", +).map { Path.of(it) } + +internal fun loadDesktopSystemTrustAnchors( + osName: String = System.getProperty("os.name").orEmpty(), +): DesktopSystemTrustAnchors { + val normalized = osName.lowercase() + return when { + normalized.startsWith("windows") -> loadWindowsRootTrustAnchors() + normalized.contains("linux") -> loadLinuxSystemCaBundle() + else -> DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.UnsupportedPlatform) + } +} + +/** + * Uses the first candidate bundle that parses completely. A bundle that is unreadable, oversized, + * empty, or malformed contributes no anchors; it never weakens validation. + */ +internal fun loadLinuxSystemCaBundle( + candidates: List = LINUX_SYSTEM_CA_BUNDLE_PATHS, +): DesktopSystemTrustAnchors { + var firstFailure: DesktopSystemTrustAnchors.Unavailable? = null + for (candidate in candidates) { + val result = readPemTrustBundle(candidate) + if (result is DesktopSystemTrustAnchors.Loaded) return result + result as DesktopSystemTrustAnchors.Unavailable + if (result.reason != DesktopSystemTrustUnavailableReason.NotFound && firstFailure == null) { + firstFailure = result + } + } + return firstFailure ?: DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.NotFound) +} + +internal fun readPemTrustBundle(path: Path): DesktopSystemTrustAnchors { + val bytes = try { + if (Files.notExists(path)) return unavailable(DesktopSystemTrustUnavailableReason.NotFound) + if (!Files.isRegularFile(path)) return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + readBoundedTrustBundle(path) ?: return unavailable(DesktopSystemTrustUnavailableReason.TooLarge) + } catch (_: NoSuchFileException) { + return unavailable(DesktopSystemTrustUnavailableReason.NotFound) + } catch (_: IOException) { + return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + } catch (_: SecurityException) { + return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + } + return parsePemTrustBundle(String(bytes, Charsets.ISO_8859_1)) +} + +/** Returns null when the file exceeds the bound, including when it grows while being read. */ +private fun readBoundedTrustBundle(path: Path): ByteArray? { + if (Files.size(path) > MAX_SYSTEM_TRUST_BUNDLE_BYTES) return null + Files.newInputStream(path).use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + if (output.size() > MAX_SYSTEM_TRUST_BUNDLE_BYTES) return null + } + return output.toByteArray() + } +} + +/** + * Accepts only complete `CERTIFICATE` blocks. OpenSSL `TRUSTED CERTIFICATE` blocks and other PEM + * labels are skipped because their auxiliary trust restrictions cannot be honored here. Any + * truncated or undecodable certificate rejects the whole bundle. + */ +internal fun parsePemTrustBundle(text: String): DesktopSystemTrustAnchors { + val factory = try { + CertificateFactory.getInstance("X.509") + } catch (_: CertificateException) { + return unavailable(DesktopSystemTrustUnavailableReason.Malformed) + } + val certificates = LinkedHashMap() + var block: StringBuilder? = null + var skippedLabel: String? = null + for (rawLine in text.lineSequence()) { + val line = rawLine.trim() + when { + skippedLabel != null -> if (line == "-----END $skippedLabel-----") skippedLabel = null + block != null -> when { + line == PEM_CERTIFICATE_END -> { + val certificate = decodeCertificate(factory, block.toString()) + ?: return unavailable(DesktopSystemTrustUnavailableReason.Malformed) + certificates.putIfAbsent(Base64.getEncoder().encodeToString(certificate.encoded), certificate) + if (certificates.size > MAX_SYSTEM_TRUST_ANCHORS) { + return unavailable(DesktopSystemTrustUnavailableReason.TooLarge) + } + block = null + } + line.startsWith("-----") -> return unavailable(DesktopSystemTrustUnavailableReason.Malformed) + else -> block.append(line) + } + line == PEM_CERTIFICATE_BEGIN -> block = StringBuilder() + line.startsWith("-----BEGIN ") && line.endsWith("-----") -> + skippedLabel = line.removePrefix("-----BEGIN ").removeSuffix("-----") + } + } + if (block != null || skippedLabel != null) return unavailable(DesktopSystemTrustUnavailableReason.Malformed) + if (certificates.isEmpty()) return unavailable(DesktopSystemTrustUnavailableReason.Empty) + return DesktopSystemTrustAnchors.Loaded(DesktopSystemTrustSource.LinuxCaBundle, certificates.values.toList()) +} + +private fun decodeCertificate(factory: CertificateFactory, base64: String): X509Certificate? { + val encoded = try { + Base64.getDecoder().decode(base64) + } catch (_: IllegalArgumentException) { + return null + } + if (encoded.isEmpty()) return null + val certificate = try { + factory.generateCertificate(ByteArrayInputStream(encoded)) as? X509Certificate + } catch (_: CertificateException) { + null + } ?: return null + // Trailing bytes after the DER structure are not a certificate the operating system trusts. + return certificate.takeIf { it.encoded.contentEquals(encoded) } +} + +/** + * Reads the Windows ROOT store through SunMSCAPI. This is the current user's logical view, which + * normally includes machine-wide roots and roots the user installed. Windows certificate + * properties such as purpose restrictions are not visible here; JSSE still requires a + * server-authentication leaf. + */ +internal fun loadWindowsRootTrustAnchors( + openStore: () -> KeyStore = { KeyStore.getInstance(WINDOWS_ROOT_STORE_TYPE).apply { load(null, null) } }, +): DesktopSystemTrustAnchors { + val store = try { + openStore() + } catch (_: KeyStoreException) { + return unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) + } catch (_: NoSuchAlgorithmException) { + return unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) + } catch (_: CertificateException) { + return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + } catch (_: IOException) { + return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + } + val certificates = LinkedHashMap() + try { + for (alias in store.aliases().asSequence()) { + if (!store.isCertificateEntry(alias)) continue + val certificate = store.getCertificate(alias) as? X509Certificate ?: continue + certificates.putIfAbsent(Base64.getEncoder().encodeToString(certificate.encoded), certificate) + if (certificates.size > MAX_SYSTEM_TRUST_ANCHORS) { + return unavailable(DesktopSystemTrustUnavailableReason.TooLarge) + } + } + } catch (_: KeyStoreException) { + return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + } catch (_: CertificateException) { + return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) + } + if (certificates.isEmpty()) return unavailable(DesktopSystemTrustUnavailableReason.Empty) + return DesktopSystemTrustAnchors.Loaded(DesktopSystemTrustSource.WindowsRootStore, certificates.values.toList()) +} + +private fun unavailable(reason: DesktopSystemTrustUnavailableReason) = + DesktopSystemTrustAnchors.Unavailable(reason) + +private const val PEM_CERTIFICATE_BEGIN = "-----BEGIN CERTIFICATE-----" +private const val PEM_CERTIFICATE_END = "-----END CERTIFICATE-----" +private const val WINDOWS_ROOT_STORE_TYPE = "Windows-ROOT" +internal const val MAX_SYSTEM_TRUST_BUNDLE_BYTES = 4L * 1024L * 1024L +internal const val MAX_SYSTEM_TRUST_ANCHORS = 2_048 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt new file mode 100644 index 000000000..780aa42ac --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt @@ -0,0 +1,156 @@ +package dev.obiente.nextcloudnative.app + +import java.io.IOException +import java.net.Socket +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.Base64 +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509ExtendedTrustManager +import javax.net.ssl.X509TrustManager +import okhttp3.OkHttpClient + +/** + * Desktop TLS trust for Nextcloud server traffic: the bundled JVM roots, plus the anchors the + * operating system trusts. A server chain must still validate completely to one anchor set, and + * OkHttp's hostname verification is unchanged. + */ +internal class DesktopTlsTrust( + val trustManager: X509TrustManager, + val sslSocketFactory: SSLSocketFactory, + val systemAnchors: DesktopSystemTrustAnchors, +) + +/** Applies the process-wide desktop trust. Never use it to relax validation for one request. */ +internal fun OkHttpClient.Builder.useDesktopSystemTrust(): OkHttpClient.Builder { + val trust = sharedDesktopTlsTrust + return sslSocketFactory(trust.sslSocketFactory, trust.trustManager) +} + +private val sharedDesktopTlsTrust: DesktopTlsTrust by lazy { + desktopTlsTrust(loadDesktopSystemTrustAnchors()) +} + +internal fun desktopTlsTrust( + systemAnchors: DesktopSystemTrustAnchors, + bundled: X509TrustManager = bundledJvmTrustManager(), +): DesktopTlsTrust { + val system = (systemAnchors as? DesktopSystemTrustAnchors.Loaded)?.let(::anchorTrustManager) + val extendedBundled = bundled as? X509ExtendedTrustManager + val effective: X509TrustManager = if (system != null && extendedBundled != null) { + DesktopCompositeTrustManager(extendedBundled, system) + } else { + bundled + } + val context = SSLContext.getInstance("TLS").apply { init(null, arrayOf(effective), null) } + return DesktopTlsTrust( + trustManager = effective, + sslSocketFactory = context.socketFactory, + systemAnchors = when { + effective !== bundled -> systemAnchors + systemAnchors is DesktopSystemTrustAnchors.Unavailable -> systemAnchors + // Anchors were read but could not be composed safely, so only the bundled roots apply. + else -> DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) + }, + ) +} + +/** The JVM default trust, including any `javax.net.ssl.trustStore` override. */ +internal fun bundledJvmTrustManager(): X509TrustManager = trustManagerFor(null) + ?: error("The Java runtime did not provide a single X.509 trust manager.") + +private fun anchorTrustManager(anchors: DesktopSystemTrustAnchors.Loaded): X509ExtendedTrustManager? { + val store = try { + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + anchors.certificates.forEachIndexed { index, certificate -> + setCertificateEntry("system-anchor-$index", certificate) + } + } + } catch (_: KeyStoreException) { + return null + } catch (_: NoSuchAlgorithmException) { + return null + } catch (_: CertificateException) { + return null + } catch (_: IOException) { + return null + } + return trustManagerFor(store) as? X509ExtendedTrustManager +} + +private fun trustManagerFor(store: KeyStore?): X509TrustManager? { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + try { + factory.init(store) + } catch (_: KeyStoreException) { + return null + } + return factory.trustManagers.filterIsInstance().singleOrNull() +} + +/** + * Accepts a server chain when the bundled roots or the operating-system anchors validate it. Both + * delegates are full JSSE PKIX trust managers, so algorithm constraints, validity, key usage, and + * the JDK CA distrust policies still apply. When both reject the chain, the bundled failure is + * rethrown with the system failure suppressed. + */ +internal class DesktopCompositeTrustManager( + private val bundled: X509ExtendedTrustManager, + private val system: X509ExtendedTrustManager, +) : X509ExtendedTrustManager() { + private val acceptedIssuers: Array = + (bundled.acceptedIssuers.asSequence() + system.acceptedIssuers.asSequence()) + .distinctBy { Base64.getEncoder().encodeToString(it.encoded) } + .toList() + .toTypedArray() + + override fun checkServerTrusted(chain: Array?, authType: String?) = + firstAccepting( + { bundled.checkServerTrusted(chain, authType) }, + { system.checkServerTrusted(chain, authType) }, + ) + + override fun checkServerTrusted(chain: Array?, authType: String?, socket: Socket?) = + firstAccepting( + { bundled.checkServerTrusted(chain, authType, socket) }, + { system.checkServerTrusted(chain, authType, socket) }, + ) + + override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = + firstAccepting( + { bundled.checkServerTrusted(chain, authType, engine) }, + { system.checkServerTrusted(chain, authType, engine) }, + ) + + // The desktop client never accepts inbound TLS clients; keep the JVM default decision. + override fun checkClientTrusted(chain: Array?, authType: String?) = + bundled.checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) = + bundled.checkClientTrusted(chain, authType, socket) + + override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = + bundled.checkClientTrusted(chain, authType, engine) + + override fun getAcceptedIssuers(): Array = acceptedIssuers.copyOf() + + private inline fun firstAccepting(bundledCheck: () -> Unit, systemCheck: () -> Unit) { + try { + bundledCheck() + } catch (bundledFailure: CertificateException) { + try { + systemCheck() + } catch (systemFailure: CertificateException) { + bundledFailure.addSuppressed(systemFailure) + throw bundledFailure + } + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/PlatformAudioPlayback.desktop.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/PlatformAudioPlayback.desktop.kt index 0ff8d0496..1b7f42f95 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/PlatformAudioPlayback.desktop.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/PlatformAudioPlayback.desktop.kt @@ -42,7 +42,7 @@ internal class DesktopAudioPlaybackEngine : PlatformAudioPlaybackEngine { private val mutableState = MutableStateFlow(NativeAudioEngineState()) override val state: StateFlow = mutableState private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val client = OkHttpClient.Builder() + private val client = OkHttpClient.Builder().useDesktopSystemTrust() .followRedirects(false) .followSslRedirects(false) .build() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt new file mode 100644 index 000000000..e38a34885 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt @@ -0,0 +1,204 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore +import java.security.KeyStoreException +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import okhttp3.tls.HeldCertificate +import okhttp3.tls.certificatePem + +class DesktopSystemTrustAnchorsTest { + private val firstAuthority = syntheticTrustAuthority("Synthetic system CA one") + private val secondAuthority = syntheticTrustAuthority("Synthetic system CA two") + + @Test + fun completeBundleWithCommentsLoadsEveryDistinctCertificate() { + val bundle = buildString { + appendLine("# Synthetic bundle with a non-ASCII comment: Főtanúsítvány") + append(firstAuthority.certificate.certificatePem()) + appendLine() + append(secondAuthority.certificate.certificatePem()) + append(firstAuthority.certificate.certificatePem()) + } + val loaded = assertIs(parsePemTrustBundle(bundle)) + assertEquals(DesktopSystemTrustSource.LinuxCaBundle, loaded.source) + assertEquals( + listOf(firstAuthority.certificate, secondAuthority.certificate), + loaded.certificates, + ) + } + + @Test + fun crlfLineEndingsAreAccepted() { + val pem = firstAuthority.certificate.certificatePem().replace("\n", "\r\n") + val loaded = assertIs(parsePemTrustBundle(pem)) + assertEquals(listOf(firstAuthority.certificate), loaded.certificates) + } + + @Test + fun anyMalformedCertificateRejectsTheWholeBundle() { + val valid = firstAuthority.certificate.certificatePem() + val body = valid.lines().filterNot { it.startsWith("-----") }.joinToString("") + val trailingBytes = Base64.getMimeEncoder(64, "\n".toByteArray()) + .encodeToString(firstAuthority.certificate.encoded + byteArrayOf(0)) + val malformedBundles = listOf( + "truncated" to valid + valid.substringBefore("-----END"), + "invalid base64" to "$valid-----BEGIN CERTIFICATE-----\n@@@@\n-----END CERTIFICATE-----\n", + "not a certificate" to "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n$valid", + "empty block" to "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n$valid", + "nested header" to "-----BEGIN CERTIFICATE-----\n$body\n-----BEGIN CERTIFICATE-----\n", + "trailing DER bytes" to "-----BEGIN CERTIFICATE-----\n$trailingBytes\n-----END CERTIFICATE-----\n", + "unterminated other label" to "$valid-----BEGIN TRUSTED CERTIFICATE-----\n$body\n", + ) + for ((name, bundle) in malformedBundles) { + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Malformed), + parsePemTrustBundle(bundle), + name, + ) + } + } + + @Test + fun openSslTrustedCertificateBlocksAreNotTreatedAsAnchors() { + val body = firstAuthority.certificate.certificatePem().lines() + .filterNot { it.startsWith("-----") } + .joinToString("\n") + val auxiliaryOnly = "-----BEGIN TRUSTED CERTIFICATE-----\n$body\n-----END TRUSTED CERTIFICATE-----\n" + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Empty), + parsePemTrustBundle(auxiliaryOnly), + ) + val mixed = assertIs( + parsePemTrustBundle(auxiliaryOnly + secondAuthority.certificate.certificatePem()), + ) + assertEquals(listOf(secondAuthority.certificate), mixed.certificates) + } + + @Test + fun emptyAndCommentOnlyBundlesAddNoAnchors() { + for (bundle in listOf("", "# no certificates\n\n")) { + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Empty), + parsePemTrustBundle(bundle), + ) + } + } + + @Test + fun bundleFilesAreBoundedAndMustBeRegularFiles() = withTrustTestDirectory { directory -> + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.NotFound), + readPemTrustBundle(directory.resolve("missing.pem")), + ) + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Unreadable), + readPemTrustBundle(directory), + ) + val oversized = directory.resolve("oversized.pem") + Files.write(oversized, ByteArray((MAX_SYSTEM_TRUST_BUNDLE_BYTES + 1).toInt()) { '#'.code.toByte() }) + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.TooLarge), + readPemTrustBundle(oversized), + ) + } + + @Test + fun linuxDiscoveryUsesTheFirstCompleteBundleAndReportsTheFirstFailure() = withTrustTestDirectory { directory -> + val missing = directory.resolve("missing.pem") + val malformed = directory.resolve("malformed.pem").also { + Files.writeString(it, "-----BEGIN CERTIFICATE-----\nAAAA\n") + } + val valid = directory.resolve("valid.pem").also { + Files.writeString(it, secondAuthority.certificate.certificatePem()) + } + + val loaded = assertIs( + loadLinuxSystemCaBundle(listOf(missing, malformed, valid)), + ) + assertEquals(listOf(secondAuthority.certificate), loaded.certificates) + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Malformed), + loadLinuxSystemCaBundle(listOf(missing, malformed)), + ) + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.NotFound), + loadLinuxSystemCaBundle(listOf(missing)), + ) + } + + @Test + fun unsupportedPlatformsAddNoAnchors() { + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.UnsupportedPlatform), + loadDesktopSystemTrustAnchors(osName = "Mac OS X"), + ) + } + + @Test + fun windowsRootStoreFailuresAddNoAnchors() { + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable), + loadWindowsRootTrustAnchors { throw KeyStoreException("Windows-ROOT not found") }, + ) + assertEquals( + DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Empty), + loadWindowsRootTrustAnchors { emptyKeyStore() }, + ) + } + + @Test + fun windowsRootStoreCertificateEntriesBecomeAnchors() { + val store = emptyKeyStore().apply { + setCertificateEntry("one", firstAuthority.certificate) + setCertificateEntry("duplicate", firstAuthority.certificate) + setCertificateEntry("two", secondAuthority.certificate) + } + val loaded = assertIs(loadWindowsRootTrustAnchors { store }) + assertEquals(DesktopSystemTrustSource.WindowsRootStore, loaded.source) + assertEquals( + setOf(firstAuthority.certificate, secondAuthority.certificate), + loaded.certificates.toSet(), + ) + assertEquals(2, loaded.certificates.size) + } + + /** Supplementary host check: the test JDK on Windows exposes the real ROOT store. */ + @Test + fun windowsHostRootStoreLoadsThroughSunMscapi() { + if (!System.getProperty("os.name").orEmpty().startsWith("Windows", ignoreCase = true)) return + val loaded = assertIs(loadDesktopSystemTrustAnchors()) + assertEquals(DesktopSystemTrustSource.WindowsRootStore, loaded.source) + assertTrue(loaded.certificates.isNotEmpty()) + } + + /** Supplementary host check: a Linux host with a standard bundle parses it completely. */ + @Test + fun linuxHostStandardBundleParsesCompletely() { + if (!System.getProperty("os.name").orEmpty().contains("Linux", ignoreCase = true)) return + if (LINUX_SYSTEM_CA_BUNDLE_PATHS.none(Files::isRegularFile)) return + assertIs(loadDesktopSystemTrustAnchors()) + } +} + +internal fun syntheticTrustAuthority(name: String): HeldCertificate = HeldCertificate.Builder() + .commonName(name) + .certificateAuthority(0) + .build() + +private fun emptyKeyStore(): KeyStore = KeyStore.getInstance("PKCS12").apply { load(null, null) } + +internal fun withTrustTestDirectory(block: (Path) -> Unit) { + val directory = Files.createTempDirectory("desktop-system-trust-test") + try { + block(directory) + } finally { + File(directory.toString()).deleteRecursively() + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt new file mode 100644 index 000000000..cb83f676e --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt @@ -0,0 +1,167 @@ +package dev.obiente.nextcloudnative.app + +import java.net.InetAddress +import java.nio.file.Files +import java.security.KeyStore +import java.security.cert.CertificateException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate + +class DesktopTlsTrustTest { + private val privateAuthority = syntheticTrustAuthority("Synthetic private CA") + private val unrelatedAuthority = syntheticTrustAuthority("Synthetic unrelated CA") + private val bundledAuthority = syntheticTrustAuthority("Synthetic bundled CA") + + @Test + fun serverIssuedByOperatingSystemTrustedPrivateCaIsAccepted() { + val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + assertIs(trust.trustManager) + withTlsServer(leafFor("localhost", privateAuthority)) { url -> + assertEquals("trusted", fetch(trust, url)) + } + } + + @Test + fun bundledRootsRemainTrustedAlongsideOperatingSystemAnchors() { + val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + withTlsServer(leafFor("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetch(trust, url)) + } + } + + @Test + fun serverIssuedByAnUntrustedCaIsRejected() { + val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + withTlsServer(leafFor("localhost", unrelatedAuthority)) { url -> + val failure = assertFailsWith { fetch(trust, url) } + val rejected = generateSequence(failure) { it.cause } + .filterIsInstance() + .first() + // Both anchor sets were consulted and both rejected the chain. + assertEquals(1, rejected.suppressed.size) + } + } + + @Test + fun hostnameMismatchIsRejectedEvenWhenTheCaIsTrusted() { + val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + withTlsServer(leafFor("nextcloud.invalid", privateAuthority)) { url -> + assertFailsWith { fetch(trust, url) } + } + } + + @Test + fun expiredLeafFromATrustedCaIsRejected() { + val now = System.currentTimeMillis() + val expired = HeldCertificate.Builder() + .commonName("localhost") + .addSubjectAlternativeName("localhost") + .validityInterval(now - TimeUnit.DAYS.toMillis(30), now - TimeUnit.DAYS.toMillis(1)) + .signedBy(privateAuthority) + .build() + val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + withTlsServer(expired) { url -> + assertFailsWith { fetch(trust, url) } + } + } + + @Test + fun malformedOrMissingBundleFallsBackToBundledTrustOnly() = withTrustTestDirectory { directory -> + val malformed = directory.resolve("ca-certificates.crt").also { + Files.writeString(it, "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n") + } + val bundled = bundledOnly() + for (anchors in listOf( + loadLinuxSystemCaBundle(listOf(malformed)), + loadLinuxSystemCaBundle(listOf(directory.resolve("missing.crt"))), + )) { + val trust = desktopTlsTrust(anchors, bundled = bundled) + assertIs(trust.systemAnchors) + assertSame(bundled, trust.trustManager) + withTlsServer(leafFor("localhost", privateAuthority)) { url -> + assertFailsWith { fetch(trust, url) } + } + withTlsServer(leafFor("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetch(trust, url)) + } + } + } + + @Test + fun acceptedIssuersIncludeBothAnchorSetsForChainCleaning() { + val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + val issuers = trust.trustManager.acceptedIssuers.toSet() + assertEquals(setOf(bundledAuthority.certificate, privateAuthority.certificate), issuers) + trust.trustManager.acceptedIssuers[0] = unrelatedAuthority.certificate + assertEquals(issuers, trust.trustManager.acceptedIssuers.toSet()) + } + + @Test + fun sharedDesktopTrustBuildsOnTheTestHost() { + val client = OkHttpClient.Builder().useDesktopSystemTrust().build() + assertTrue(client.x509TrustManager != null) + } + + private fun systemAnchors(vararg authorities: HeldCertificate) = + DesktopSystemTrustAnchors.Loaded( + DesktopSystemTrustSource.LinuxCaBundle, + authorities.map { it.certificate }, + ) + + /** Stands in for the bundled JVM roots so tests never depend on public CAs or the network. */ + private fun bundledOnly(): X509TrustManager { + val store = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setCertificateEntry("bundled", bundledAuthority.certificate) + } + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(store) + return factory.trustManagers.filterIsInstance().single() + } + + private fun leafFor(host: String, issuer: HeldCertificate): HeldCertificate = HeldCertificate.Builder() + .commonName(host) + .addSubjectAlternativeName(host) + .signedBy(issuer) + .build() + + private fun withTlsServer(leaf: HeldCertificate, block: (String) -> Unit) { + val serverCertificates = HandshakeCertificates.Builder().heldCertificate(leaf).build() + MockWebServer().use { server -> + server.useHttps(serverCertificates.sslSocketFactory()) + server.enqueue(MockResponse.Builder().code(200).body("trusted").build()) + server.start(LOOPBACK, 0) + block("https://localhost:${server.port}/status.php") + } + } + + private fun fetch(trust: DesktopTlsTrust, url: String): String { + val client = OkHttpClient.Builder() + .sslSocketFactory(trust.sslSocketFactory, trust.trustManager) + .retryOnConnectionFailure(false) + // One loopback route keeps the TLS failure from being masked by another address family. + .dns { hostname -> if (hostname == "localhost") listOf(LOOPBACK) else Dns.SYSTEM.lookup(hostname) } + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + return client.newCall(Request.Builder().url(url).build()).execute().use { it.body.string() } + } +} + +private val LOOPBACK: InetAddress = InetAddress.getByAddress("localhost", byteArrayOf(127, 0, 0, 1)) diff --git a/website/content/guides/desktop-getting-started.md b/website/content/guides/desktop-getting-started.md index c0f8cdb01..049598612 100644 --- a/website/content/guides/desktop-getting-started.md +++ b/website/content/guides/desktop-getting-started.md @@ -33,6 +33,8 @@ Download the exact package attached to a GitHub prerelease: DEB or RPM for the m Open nati.ve, enter the complete `https://` server address, and finish sign-in on the server's Login Flow page. The app stores the generated app password in Linux Secret Service or Windows Credential Manager. Home should then show the account status. A macOS package must not be used for an account until supported Keychain login is implemented. +If the server uses a private certificate authority, never work around a certificate warning. In builds containing operating-system certificate trust, add the authority to the Linux system CA bundle with your distribution's tool or to the Windows Trusted Root Certification Authorities store, then restart nati.ve. Check the [release notes](https://github.com/Obiente/native/releases) for the behavior included in your build, and see [sign-in diagnostics](https://github.com/obiente/native/blob/main/docs/login-diagnostics.md#server-certificates-and-private-certificate-authorities) for the exact bundle locations. + ## 2. Use desktop navigation and installed app workspaces @capture-alt: nati.ve desktop Apps workspace with pinned tools, recent work, categories, search, installed app cards, and the persistent sidebar From a45ebe0d85322ae9e745beb2dab4915345f25f45 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:39:16 +0000 Subject: [PATCH 2/8] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 5976f9d17..6aab32dc4 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -508,7 +508,7 @@ "gradle/wrapper/gradle-wrapper.properties": "aef287d114ce3153c3d535697a61928f5034990d570cbb2e93df549e9671483d", "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "0092b8bc6445328384db348a4fd5eb173c5fe4133d170a193fa7c399dcd411ab", - "ui/build.gradle.kts": "e36e00d547f1981e7dae3441102faa9bad3310ea5e0feda90b3c355b154ee1b2", + "ui/build.gradle.kts": "e437f2e8fb45b2042937dd3038b1b0c6135595c6c2e47f1be207c899a3a558b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountHomeWorkspaceStorage.kt": "5e0fa144df65840d15882c7da1dac19041083a302ced40578d07747f8ec7d828", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPlaybackRetirement.kt": "01069d5a599bbf9dce5c9a152bb771f4bd524de7fb2d74db9c6bf9b774b31cb2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "d247d7d310e8579717e243bce827e8ab6d8a8e5a6df011b481e9bc6fc72da862", From 1af546ff7eba0c32c085d6226623bb7c149f3ac2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 19:18:46 +0200 Subject: [PATCH 3/8] fix(desktop): let the Windows chain engine decide OS certificate trust Copying every Windows-ROOT entry into a Java keystore promoted each root to an unrestricted TLS anchor. That ignored the Disallowed store and purpose restrictions on stored certificates, and it read only the current-user ROOT view. When the bundled roots reject a chain on Windows, the presented chain now goes to the current user's Windows chain engine through JNA (CertGetCertificateChain with the server-authentication usage, then the SSL chain policy). Windows store policy therefore stays in force, and both user and machine roots apply. Chain building uses cached data only and never fetches AIA or root updates. Hostname verification remains with OkHttp. The SunMSCAPI runtime module is no longer packaged. Linux keeps the system CA bundle anchors behind a PKIX trust manager. --- PLATFORMS.md | 35 ++- docs/login-diagnostics.md | 16 +- ui/build.gradle.kts | 3 - .../app/DesktopSystemTrustAnchors.kt | 68 +---- .../nextcloudnative/app/DesktopTlsTrust.kt | 83 ++++-- .../app/WindowsChainEngineTrust.kt | 267 ++++++++++++++++++ .../app/DesktopSystemTrustAnchorsTest.kt | 70 +---- .../app/DesktopTlsTestFixtures.kt | 84 ++++++ .../app/DesktopTlsTrustTest.kt | 183 ++++++------ .../app/WindowsChainEngineTrustTest.kt | 180 ++++++++++++ 10 files changed, 728 insertions(+), 261 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTestFixtures.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt diff --git a/PLATFORMS.md b/PLATFORMS.md index 0333c3bd7..02764f686 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -348,19 +348,30 @@ published installer. guarantees, not claims about a published installer; the code may have changed. - Desktop clients that contact the Nextcloud server use one process-wide trust - manager from `DesktopTlsTrust.kt`. It accepts a chain when either the bundled - Java runtime roots or the operating-system anchors validate it completely. - OkHttp hostname verification is unchanged, and no trust-all path exists. -- `DesktopSystemTrustAnchors.kt` reads the first complete Linux system CA - bundle or the Windows `Windows-ROOT` store through SunMSCAPI. Windows - packages add the `jdk.crypto.mscapi` runtime module for this. macOS reads no - system anchors yet. -- An unreadable, oversized, truncated, or malformed bundle contributes no - anchors. Update checks and project news keep the bundled roots only. + manager from `DesktopTlsTrust.kt`. It accepts a chain when the bundled Java + runtime roots validate it, or, after they reject it, when the operating + system's trust decision accepts it. OkHttp hostname verification is + unchanged, and no trust-all path exists. +- Linux: `DesktopSystemTrustAnchors.kt` reads the first complete system CA + bundle into a PKIX trust manager limited to those anchors. An unreadable, + oversized, truncated, or malformed bundle contributes no anchors. +- Windows: `WindowsChainEngineTrust.kt` passes the presented chain to the + current user's Windows chain engine (`CertGetCertificateChain` with the + server-authentication usage, then the SSL policy) through JNA. Windows + roots are not copied into Java, so Windows store policy such as the + Disallowed store and certificate purpose restrictions stays in force. Chain + building uses cached data only and does not fetch AIA or root updates. +- macOS reads no system trust yet. Update checks and project news keep the + bundled roots only. - `DesktopTlsTrustTest` and `DesktopSystemTrustAnchorsTest` cover synthetic CAs, - untrusted and expired chains, hostname mismatch, and malformed or missing - bundles. A Windows test host also loads its real ROOT store. No test - establishes behavior inside a Flatpak or other sandbox. + untrusted and expired chains, hostname mismatch, malformed or missing + bundles, and the Windows verdict mapping through an injected verifier. + `WindowsChainEngineTrustTest` runs only on Windows hosts and drives the real + chain engine with an in-memory exclusive root: a trusted root, an unrelated + root, an expired leaf, and a root whose stored purpose excludes server + authentication. No test modifies a Windows certificate store, so Disallowed + handling relies on documented Windows engine behavior. No test establishes + behavior inside a Flatpak or other sandbox. - User-facing guidance is in [sign-in diagnostics](docs/login-diagnostics.md#server-certificates-and-private-certificate-authorities). diff --git a/docs/login-diagnostics.md b/docs/login-diagnostics.md index 9f56f4e4d..136d8e8e1 100644 --- a/docs/login-diagnostics.md +++ b/docs/login-diagnostics.md @@ -44,8 +44,8 @@ certificate must chain to a trusted certificate authority (CA) and match the server name. On Linux and Windows desktop, connections to the Nextcloud server trust the -CAs bundled with the app's Java runtime and, in addition, the CAs the operating -system trusts: +CAs bundled with the app's Java runtime and, when those reject a certificate, +the operating system's trust decision: - **Linux** reads the first complete system CA bundle among `/etc/ssl/certs/ca-certificates.crt`, `/etc/pki/tls/certs/ca-bundle.crt`, @@ -54,13 +54,17 @@ system trusts: as `update-ca-certificates` (Debian and Ubuntu), `trust anchor` (Arch Linux), or `update-ca-trust` (Fedora), then restart nati.ve. If `curl` verifies the server without extra options, the bundle normally contains the CA. -- **Windows** reads the current user's view of the Trusted Root Certification - Authorities store, which normally includes machine-wide roots and roots the - user installed. Restart nati.ve after adding a root. +- **Windows** asks the Windows certificate chain engine for the current user + to validate the chain. It honors roots installed for the user or the whole + machine, certificates in the Untrusted Certificates (Disallowed) store, and + purpose restrictions set on stored certificates. Chain building uses only + locally available data: it does not download missing intermediate + certificates or root updates, so the server must send its intermediates. + Restart nati.ve after adding a root. - **macOS** desktop does not yet read the Keychain; it uses only the bundled Java roots. -The system CAs are read once when the app starts. An unreadable, oversized, +The Linux bundle is read once when the app starts. An unreadable, oversized, truncated, or malformed bundle adds no CAs; the bundled roots still apply. Inside a sandbox such as Flatpak, the app sees only the CA bundle that the sandbox exposes at those paths, which might not include CAs added on the host. diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 246553358..dbd75c0b0 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -257,9 +257,6 @@ compose.desktop { nativeDistributions { modules("jdk.security.auth") - // SunMSCAPI exposes the Windows ROOT store so OS-trusted CAs are honored for TLS. - // The module exists only in Windows JDKs, and jpackage builds each OS natively. - if (System.getProperty("os.name").startsWith("Windows")) modules("jdk.crypto.mscapi") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) // Preserve Windows/Linux launcher paths used by existing integrations. packageName = if (System.getProperty("os.name").startsWith("Mac")) "nati.ve" else "NextcloudNative" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt index a9aa1edac..d0a30326e 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt @@ -6,20 +6,11 @@ import java.io.IOException import java.nio.file.Files import java.nio.file.NoSuchFileException import java.nio.file.Path -import java.security.KeyStore -import java.security.KeyStoreException -import java.security.NoSuchAlgorithmException import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.Base64 -/** Operating-system trust source used in addition to the bundled JVM roots. */ -internal enum class DesktopSystemTrustSource { - LinuxCaBundle, - WindowsRootStore, -} - /** Bounded, non-secret reason why no operating-system anchors were added. */ internal enum class DesktopSystemTrustUnavailableReason { UnsupportedPlatform, @@ -31,11 +22,9 @@ internal enum class DesktopSystemTrustUnavailableReason { StoreUnavailable, } +/** Trust anchors read from a Linux system CA bundle. */ internal sealed interface DesktopSystemTrustAnchors { - data class Loaded( - val source: DesktopSystemTrustSource, - val certificates: List, - ) : DesktopSystemTrustAnchors { + data class Loaded(val certificates: List) : DesktopSystemTrustAnchors { init { require(certificates.isNotEmpty()) } @@ -62,17 +51,6 @@ internal val LINUX_SYSTEM_CA_BUNDLE_PATHS: List = listOf( "/etc/ssl/cert.pem", ).map { Path.of(it) } -internal fun loadDesktopSystemTrustAnchors( - osName: String = System.getProperty("os.name").orEmpty(), -): DesktopSystemTrustAnchors { - val normalized = osName.lowercase() - return when { - normalized.startsWith("windows") -> loadWindowsRootTrustAnchors() - normalized.contains("linux") -> loadLinuxSystemCaBundle() - else -> DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.UnsupportedPlatform) - } -} - /** * Uses the first candidate bundle that parses completely. A bundle that is unreadable, oversized, * empty, or malformed contributes no anchors; it never weakens validation. @@ -161,7 +139,7 @@ internal fun parsePemTrustBundle(text: String): DesktopSystemTrustAnchors { } if (block != null || skippedLabel != null) return unavailable(DesktopSystemTrustUnavailableReason.Malformed) if (certificates.isEmpty()) return unavailable(DesktopSystemTrustUnavailableReason.Empty) - return DesktopSystemTrustAnchors.Loaded(DesktopSystemTrustSource.LinuxCaBundle, certificates.values.toList()) + return DesktopSystemTrustAnchors.Loaded(certificates.values.toList()) } private fun decodeCertificate(factory: CertificateFactory, base64: String): X509Certificate? { @@ -180,50 +158,10 @@ private fun decodeCertificate(factory: CertificateFactory, base64: String): X509 return certificate.takeIf { it.encoded.contentEquals(encoded) } } -/** - * Reads the Windows ROOT store through SunMSCAPI. This is the current user's logical view, which - * normally includes machine-wide roots and roots the user installed. Windows certificate - * properties such as purpose restrictions are not visible here; JSSE still requires a - * server-authentication leaf. - */ -internal fun loadWindowsRootTrustAnchors( - openStore: () -> KeyStore = { KeyStore.getInstance(WINDOWS_ROOT_STORE_TYPE).apply { load(null, null) } }, -): DesktopSystemTrustAnchors { - val store = try { - openStore() - } catch (_: KeyStoreException) { - return unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) - } catch (_: NoSuchAlgorithmException) { - return unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) - } catch (_: CertificateException) { - return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) - } catch (_: IOException) { - return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) - } - val certificates = LinkedHashMap() - try { - for (alias in store.aliases().asSequence()) { - if (!store.isCertificateEntry(alias)) continue - val certificate = store.getCertificate(alias) as? X509Certificate ?: continue - certificates.putIfAbsent(Base64.getEncoder().encodeToString(certificate.encoded), certificate) - if (certificates.size > MAX_SYSTEM_TRUST_ANCHORS) { - return unavailable(DesktopSystemTrustUnavailableReason.TooLarge) - } - } - } catch (_: KeyStoreException) { - return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) - } catch (_: CertificateException) { - return unavailable(DesktopSystemTrustUnavailableReason.Unreadable) - } - if (certificates.isEmpty()) return unavailable(DesktopSystemTrustUnavailableReason.Empty) - return DesktopSystemTrustAnchors.Loaded(DesktopSystemTrustSource.WindowsRootStore, certificates.values.toList()) -} - private fun unavailable(reason: DesktopSystemTrustUnavailableReason) = DesktopSystemTrustAnchors.Unavailable(reason) private const val PEM_CERTIFICATE_BEGIN = "-----BEGIN CERTIFICATE-----" private const val PEM_CERTIFICATE_END = "-----END CERTIFICATE-----" -private const val WINDOWS_ROOT_STORE_TYPE = "Windows-ROOT" internal const val MAX_SYSTEM_TRUST_BUNDLE_BYTES = 4L * 1024L * 1024L internal const val MAX_SYSTEM_TRUST_ANCHORS = 2_048 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt index 780aa42ac..15d989be6 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt @@ -17,16 +17,39 @@ import javax.net.ssl.X509TrustManager import okhttp3.OkHttpClient /** - * Desktop TLS trust for Nextcloud server traffic: the bundled JVM roots, plus the anchors the - * operating system trusts. A server chain must still validate completely to one anchor set, and + * Desktop TLS trust for Nextcloud server traffic: the bundled JVM roots, plus the operating + * system's trust decision. A server chain must still validate completely through one of them, and * OkHttp's hostname verification is unchanged. */ internal class DesktopTlsTrust( val trustManager: X509TrustManager, val sslSocketFactory: SSLSocketFactory, - val systemAnchors: DesktopSystemTrustAnchors, + val systemTrust: DesktopSystemTrustStatus, ) +/** Where the operating-system trust decision comes from. */ +internal enum class DesktopSystemTrustSource { + LinuxCaBundle, + WindowsChainEngine, +} + +/** Bounded, non-secret summary of whether operating-system trust is active. */ +internal sealed interface DesktopSystemTrustStatus { + data class Active(val source: DesktopSystemTrustSource) : DesktopSystemTrustStatus + + data class Unavailable(val reason: DesktopSystemTrustUnavailableReason) : DesktopSystemTrustStatus +} + +/** Operating-system trust consulted only after the bundled roots reject a server chain. */ +internal sealed interface DesktopSystemTrust { + data class Available( + val source: DesktopSystemTrustSource, + val trustManager: X509ExtendedTrustManager, + ) : DesktopSystemTrust + + data class Unavailable(val reason: DesktopSystemTrustUnavailableReason) : DesktopSystemTrust +} + /** Applies the process-wide desktop trust. Never use it to relax validation for one request. */ internal fun OkHttpClient.Builder.useDesktopSystemTrust(): OkHttpClient.Builder { val trust = sharedDesktopTlsTrust @@ -34,30 +57,50 @@ internal fun OkHttpClient.Builder.useDesktopSystemTrust(): OkHttpClient.Builder } private val sharedDesktopTlsTrust: DesktopTlsTrust by lazy { - desktopTlsTrust(loadDesktopSystemTrustAnchors()) + desktopTlsTrust(loadDesktopSystemTrust()) +} + +internal fun loadDesktopSystemTrust( + osName: String = System.getProperty("os.name").orEmpty(), +): DesktopSystemTrust { + val normalized = osName.lowercase() + return when { + normalized.startsWith("windows") -> windowsChainEngineTrust() + normalized.contains("linux") -> linuxBundleTrust(loadLinuxSystemCaBundle()) + else -> DesktopSystemTrust.Unavailable(DesktopSystemTrustUnavailableReason.UnsupportedPlatform) + } +} + +/** Turns Linux bundle anchors into a full JSSE PKIX trust manager limited to those anchors. */ +internal fun linuxBundleTrust(anchors: DesktopSystemTrustAnchors): DesktopSystemTrust = when (anchors) { + is DesktopSystemTrustAnchors.Unavailable -> DesktopSystemTrust.Unavailable(anchors.reason) + is DesktopSystemTrustAnchors.Loaded -> anchorTrustManager(anchors) + ?.let { DesktopSystemTrust.Available(DesktopSystemTrustSource.LinuxCaBundle, it) } + ?: DesktopSystemTrust.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) } internal fun desktopTlsTrust( - systemAnchors: DesktopSystemTrustAnchors, + systemTrust: DesktopSystemTrust, bundled: X509TrustManager = bundledJvmTrustManager(), ): DesktopTlsTrust { - val system = (systemAnchors as? DesktopSystemTrustAnchors.Loaded)?.let(::anchorTrustManager) val extendedBundled = bundled as? X509ExtendedTrustManager - val effective: X509TrustManager = if (system != null && extendedBundled != null) { - DesktopCompositeTrustManager(extendedBundled, system) - } else { - bundled + val (effective, status) = when { + systemTrust is DesktopSystemTrust.Unavailable -> + bundled to DesktopSystemTrustStatus.Unavailable(systemTrust.reason) + // Without the extended bundled manager, handshake context could not be preserved. + extendedBundled == null -> + bundled to DesktopSystemTrustStatus.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) + else -> { + systemTrust as DesktopSystemTrust.Available + DesktopCompositeTrustManager(extendedBundled, systemTrust.trustManager) to + DesktopSystemTrustStatus.Active(systemTrust.source) + } } val context = SSLContext.getInstance("TLS").apply { init(null, arrayOf(effective), null) } return DesktopTlsTrust( trustManager = effective, sslSocketFactory = context.socketFactory, - systemAnchors = when { - effective !== bundled -> systemAnchors - systemAnchors is DesktopSystemTrustAnchors.Unavailable -> systemAnchors - // Anchors were read but could not be composed safely, so only the bundled roots apply. - else -> DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) - }, + systemTrust = status, ) } @@ -96,10 +139,10 @@ private fun trustManagerFor(store: KeyStore?): X509TrustManager? { } /** - * Accepts a server chain when the bundled roots or the operating-system anchors validate it. Both - * delegates are full JSSE PKIX trust managers, so algorithm constraints, validity, key usage, and - * the JDK CA distrust policies still apply. When both reject the chain, the bundled failure is - * rethrown with the system failure suppressed. + * Accepts a server chain when the bundled roots or the operating-system trust validate it. The + * bundled delegate is a full JSSE PKIX trust manager. The system delegate is either a PKIX manager + * limited to Linux bundle anchors or the Windows chain engine with its own store policy. When both + * reject the chain, the bundled failure is rethrown with the system failure suppressed. */ internal class DesktopCompositeTrustManager( private val bundled: X509ExtendedTrustManager, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt new file mode 100644 index 000000000..e995387e1 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt @@ -0,0 +1,267 @@ +package dev.obiente.nextcloudnative.app + +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.platform.win32.WinCrypt +import com.sun.jna.ptr.PointerByReference +import com.sun.jna.win32.StdCallLibrary +import java.lang.ref.Reference +import java.net.Socket +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedTrustManager + +/** Outcome of asking the Windows certificate chain engine about one presented server chain. */ +internal sealed interface WindowsChainVerdict { + data object Trusted : WindowsChainVerdict + + /** Windows evaluated the chain and rejected it. Codes are Win32 trust and policy values. */ + data class Rejected(val trustErrorStatus: Int, val policyError: Int) : WindowsChainVerdict + + /** The native call failed before Windows could evaluate the chain. */ + data class Failed(val win32Error: Int) : WindowsChainVerdict +} + +/** Narrow boundary over the native chain engine so policy mapping is testable without Windows. */ +internal fun interface WindowsServerChainVerifier { + fun verify(encodedChain: List): WindowsChainVerdict +} + +/** + * Operating-system trust for Windows. The Windows chain engine, not a copy of its roots, decides + * whether a chain is trusted, so roots installed for the user or the machine, the Disallowed store, + * and purpose restrictions on stored certificates all apply. Hostname verification stays with OkHttp. + */ +internal class WindowsChainEngineTrustManager( + private val verifier: WindowsServerChainVerifier, +) : X509ExtendedTrustManager() { + override fun checkServerTrusted(chain: Array?, authType: String?) = verify(chain) + + override fun checkServerTrusted(chain: Array?, authType: String?, socket: Socket?) = + verify(chain) + + override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = + verify(chain) + + override fun checkClientTrusted(chain: Array?, authType: String?) = rejectClient() + + override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) = + rejectClient() + + override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = + rejectClient() + + // Windows roots are not enumerated; the chain engine owns that decision. + override fun getAcceptedIssuers(): Array = emptyArray() + + private fun verify(chain: Array?) { + if (chain.isNullOrEmpty()) throw CertificateException("The server did not present a certificate chain.") + if (chain.size > MAX_WINDOWS_VERIFIED_CHAIN_LENGTH) { + throw CertificateException("The server certificate chain is too long.") + } + val encoded = try { + chain.map { it.encoded } + } catch (failure: CertificateEncodingException) { + throw CertificateException("The server certificate chain could not be encoded.", failure) + } + when (val verdict = verifier.verify(encoded)) { + WindowsChainVerdict.Trusted -> Unit + is WindowsChainVerdict.Rejected -> throw CertificateException( + "Windows rejected the server certificate chain (trust 0x%08X, policy 0x%08X)." + .format(verdict.trustErrorStatus, verdict.policyError), + ) + is WindowsChainVerdict.Failed -> throw CertificateException( + "Windows could not evaluate the server certificate chain (error %d).".format(verdict.win32Error), + ) + } + } + + private fun rejectClient(): Nothing = + throw CertificateException("Windows operating-system trust is used only for server certificates.") +} + +/** Loads the Windows chain engine trust, or reports why it is unavailable. */ +internal fun windowsChainEngineTrust(): DesktopSystemTrust { + val verifier = try { + JnaWindowsServerChainVerifier() + } catch (_: UnsatisfiedLinkError) { + return DesktopSystemTrust.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) + } + return DesktopSystemTrust.Available( + DesktopSystemTrustSource.WindowsChainEngine, + WindowsChainEngineTrustManager(verifier), + ) +} + +/** + * Builds the chain with `CertGetCertificateChain` and checks it with the SSL server policy. + * [chainEngine] null selects the current user's default engine. Only locally cached data is used: + * missing intermediates and root updates are never downloaded from URLs a server certificate names. + */ +internal class JnaWindowsServerChainVerifier( + private val chainEngine: Pointer? = null, +) : WindowsServerChainVerifier { + private val crypt32: WindowsCrypt32 = WindowsCrypt32.instance + + override fun verify(encodedChain: List): WindowsChainVerdict { + require(encodedChain.isNotEmpty()) + val store = crypt32.CertOpenStore(CERT_STORE_PROV_MEMORY, 0, null, 0, null) + ?: return WindowsChainVerdict.Failed(Native.getLastError()) + var leaf: Pointer? = null + try { + encodedChain.forEachIndexed { index, encoded -> + val context = if (index == 0) PointerByReference() else null + if (!crypt32.CertAddEncodedCertificateToStore( + store, X509_ASN_ENCODING, encoded, encoded.size, CERT_STORE_ADD_ALWAYS, context, + ) + ) { + return WindowsChainVerdict.Failed(Native.getLastError()) + } + if (context != null) leaf = context.value + } + return verifyLeaf(requireNotNull(leaf), store) + } finally { + leaf?.let(crypt32::CertFreeCertificateContext) + crypt32.CertCloseStore(store, 0) + } + } + + private fun verifyLeaf(leaf: Pointer, additionalStore: Pointer): WindowsChainVerdict { + val serverAuthOid = Memory(SERVER_AUTH_OID.length + 1L).apply { setString(0, SERVER_AUTH_OID, "US-ASCII") } + val usageArray = Memory(Native.POINTER_SIZE.toLong()).apply { setPointer(0, serverAuthOid) } + val chainPara = WinCrypt.CERT_CHAIN_PARA().apply { + cbSize = size() + RequestedUsage.dwType = USAGE_MATCH_TYPE_AND + RequestedUsage.Usage.cUsageIdentifier = 1 + RequestedUsage.Usage.rgpszUsageIdentifier = usageArray + } + try { + val chainReference = PointerByReference() + if (!crypt32.CertGetCertificateChain( + chainEngine, leaf, null, additionalStore, chainPara, CHAIN_FLAGS, null, chainReference, + ) + ) { + return WindowsChainVerdict.Failed(Native.getLastError()) + } + val chain = chainReference.value ?: return WindowsChainVerdict.Failed(0) + try { + return checkSslPolicy(chain) + } finally { + crypt32.CertFreeCertificateChain(chain) + } + } finally { + // The usage array points at native memory that only these objects own. + Reference.reachabilityFence(serverAuthOid) + Reference.reachabilityFence(usageArray) + } + } + + private fun checkSslPolicy(chain: Pointer): WindowsChainVerdict { + val sslPara = WindowsSslExtraPolicyPara().apply { + cbSize = size() + dwAuthType = AUTHTYPE_SERVER + // OkHttp verifies the hostname after the handshake. + fdwChecks = SECURITY_FLAG_IGNORE_CERT_CN_INVALID + pwszServerName = null + write() + } + val policyPara = WinCrypt.CERT_CHAIN_POLICY_PARA().apply { + cbSize = size() + dwFlags = 0 + pvExtraPolicyPara = sslPara.pointer + } + val policyStatus = WinCrypt.CERT_CHAIN_POLICY_STATUS().apply { cbSize = size() } + try { + if (!crypt32.CertVerifyCertificateChainPolicy(CERT_CHAIN_POLICY_SSL, chain, policyPara, policyStatus)) { + return WindowsChainVerdict.Failed(Native.getLastError()) + } + } finally { + Reference.reachabilityFence(sslPara) + } + // CERT_CHAIN_CONTEXT starts with cbSize followed by CERT_TRUST_STATUS.dwErrorStatus. + val trustErrorStatus = chain.getInt(4) + return if (trustErrorStatus == 0 && policyStatus.dwError == 0) { + WindowsChainVerdict.Trusted + } else { + WindowsChainVerdict.Rejected(trustErrorStatus, policyStatus.dwError) + } + } +} + +@Structure.FieldOrder("cbSize", "dwAuthType", "fdwChecks", "pwszServerName") +internal class WindowsSslExtraPolicyPara : Structure() { + @JvmField var cbSize: Int = 0 + @JvmField var dwAuthType: Int = 0 + @JvmField var fdwChecks: Int = 0 + @JvmField var pwszServerName: Pointer? = null +} + +@Suppress("FunctionName") +internal interface WindowsCrypt32 : StdCallLibrary { + fun CertOpenStore( + storeProvider: Pointer, + encodingType: Int, + cryptProvider: Pointer?, + flags: Int, + parameter: Pointer?, + ): Pointer? + + fun CertAddEncodedCertificateToStore( + store: Pointer, + encodingType: Int, + encoded: ByteArray, + encodedLength: Int, + addDisposition: Int, + context: PointerByReference?, + ): Boolean + + fun CertGetCertificateChain( + chainEngine: Pointer?, + certificateContext: Pointer, + time: Pointer?, + additionalStore: Pointer?, + chainPara: WinCrypt.CERT_CHAIN_PARA, + flags: Int, + reserved: Pointer?, + chainContext: PointerByReference, + ): Boolean + + fun CertVerifyCertificateChainPolicy( + policyOid: Pointer, + chainContext: Pointer, + policyPara: WinCrypt.CERT_CHAIN_POLICY_PARA, + policyStatus: WinCrypt.CERT_CHAIN_POLICY_STATUS, + ): Boolean + + fun CertFreeCertificateChain(chainContext: Pointer) + + fun CertFreeCertificateContext(certificateContext: Pointer): Boolean + + fun CertCloseStore(store: Pointer, flags: Int): Boolean + + companion object { + val instance: WindowsCrypt32 by lazy { Native.load("crypt32", WindowsCrypt32::class.java) } + } +} + +internal val CERT_STORE_PROV_MEMORY: Pointer = Pointer.createConstant(2) +private val CERT_CHAIN_POLICY_SSL: Pointer = Pointer.createConstant(4) +internal const val X509_ASN_ENCODING = 0x00000001 +internal const val CERT_STORE_ADD_ALWAYS = 4 +private const val USAGE_MATCH_TYPE_AND = 0 +private const val SERVER_AUTH_OID = "1.3.6.1.5.5.7.3.1" +private const val AUTHTYPE_SERVER = 2 +private const val SECURITY_FLAG_IGNORE_CERT_CN_INVALID = 0x00001000 +private const val CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL = 0x00000004 +private const val CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE = 0x00000100 +private const val CERT_CHAIN_DISABLE_MD2_MD4 = 0x00001000 +private const val CERT_CHAIN_DISABLE_AIA = 0x00002000 +private const val CHAIN_FLAGS = CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL or + CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE or + CERT_CHAIN_DISABLE_MD2_MD4 or + CERT_CHAIN_DISABLE_AIA +internal const val MAX_WINDOWS_VERIFIED_CHAIN_LENGTH = 16 diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt index e38a34885..999da9037 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt @@ -1,16 +1,10 @@ package dev.obiente.nextcloudnative.app -import java.io.File import java.nio.file.Files -import java.nio.file.Path -import java.security.KeyStore -import java.security.KeyStoreException import java.util.Base64 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs -import kotlin.test.assertTrue -import okhttp3.tls.HeldCertificate import okhttp3.tls.certificatePem class DesktopSystemTrustAnchorsTest { @@ -27,7 +21,6 @@ class DesktopSystemTrustAnchorsTest { append(firstAuthority.certificate.certificatePem()) } val loaded = assertIs(parsePemTrustBundle(bundle)) - assertEquals(DesktopSystemTrustSource.LinuxCaBundle, loaded.source) assertEquals( listOf(firstAuthority.certificate, secondAuthority.certificate), loaded.certificates, @@ -133,72 +126,11 @@ class DesktopSystemTrustAnchorsTest { ) } - @Test - fun unsupportedPlatformsAddNoAnchors() { - assertEquals( - DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.UnsupportedPlatform), - loadDesktopSystemTrustAnchors(osName = "Mac OS X"), - ) - } - - @Test - fun windowsRootStoreFailuresAddNoAnchors() { - assertEquals( - DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable), - loadWindowsRootTrustAnchors { throw KeyStoreException("Windows-ROOT not found") }, - ) - assertEquals( - DesktopSystemTrustAnchors.Unavailable(DesktopSystemTrustUnavailableReason.Empty), - loadWindowsRootTrustAnchors { emptyKeyStore() }, - ) - } - - @Test - fun windowsRootStoreCertificateEntriesBecomeAnchors() { - val store = emptyKeyStore().apply { - setCertificateEntry("one", firstAuthority.certificate) - setCertificateEntry("duplicate", firstAuthority.certificate) - setCertificateEntry("two", secondAuthority.certificate) - } - val loaded = assertIs(loadWindowsRootTrustAnchors { store }) - assertEquals(DesktopSystemTrustSource.WindowsRootStore, loaded.source) - assertEquals( - setOf(firstAuthority.certificate, secondAuthority.certificate), - loaded.certificates.toSet(), - ) - assertEquals(2, loaded.certificates.size) - } - - /** Supplementary host check: the test JDK on Windows exposes the real ROOT store. */ - @Test - fun windowsHostRootStoreLoadsThroughSunMscapi() { - if (!System.getProperty("os.name").orEmpty().startsWith("Windows", ignoreCase = true)) return - val loaded = assertIs(loadDesktopSystemTrustAnchors()) - assertEquals(DesktopSystemTrustSource.WindowsRootStore, loaded.source) - assertTrue(loaded.certificates.isNotEmpty()) - } - /** Supplementary host check: a Linux host with a standard bundle parses it completely. */ @Test fun linuxHostStandardBundleParsesCompletely() { if (!System.getProperty("os.name").orEmpty().contains("Linux", ignoreCase = true)) return if (LINUX_SYSTEM_CA_BUNDLE_PATHS.none(Files::isRegularFile)) return - assertIs(loadDesktopSystemTrustAnchors()) - } -} - -internal fun syntheticTrustAuthority(name: String): HeldCertificate = HeldCertificate.Builder() - .commonName(name) - .certificateAuthority(0) - .build() - -private fun emptyKeyStore(): KeyStore = KeyStore.getInstance("PKCS12").apply { load(null, null) } - -internal fun withTrustTestDirectory(block: (Path) -> Unit) { - val directory = Files.createTempDirectory("desktop-system-trust-test") - try { - block(directory) - } finally { - File(directory.toString()).deleteRecursively() + assertIs(loadLinuxSystemCaBundle()) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTestFixtures.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTestFixtures.kt new file mode 100644 index 000000000..e9b9b363f --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTestFixtures.kt @@ -0,0 +1,84 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.net.InetAddress +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore +import java.util.concurrent.TimeUnit +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate + +/** Synthetic certificates and a loopback TLS server for desktop trust tests. No real hosts. */ +internal fun syntheticTrustAuthority(name: String): HeldCertificate = HeldCertificate.Builder() + .commonName(name) + .certificateAuthority(0) + .build() + +internal fun syntheticLeaf(host: String, issuer: HeldCertificate): HeldCertificate = HeldCertificate.Builder() + .commonName(host) + .addSubjectAlternativeName(host) + .signedBy(issuer) + .build() + +internal fun syntheticExpiredLeaf(host: String, issuer: HeldCertificate): HeldCertificate { + val now = System.currentTimeMillis() + return HeldCertificate.Builder() + .commonName(host) + .addSubjectAlternativeName(host) + .validityInterval(now - TimeUnit.DAYS.toMillis(30), now - TimeUnit.DAYS.toMillis(1)) + .signedBy(issuer) + .build() +} + +/** A PKIX trust manager limited to [authorities], standing in for the bundled JVM roots. */ +internal fun pkixTrustManagerFor(vararg authorities: HeldCertificate): X509TrustManager { + val store = KeyStore.getInstance("PKCS12").apply { + load(null, null) + authorities.forEachIndexed { index, authority -> setCertificateEntry("anchor-$index", authority.certificate) } + } + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(store) + return factory.trustManagers.filterIsInstance().single() +} + +internal fun withSyntheticTlsServer(leaf: HeldCertificate, block: (String) -> Unit) { + val serverCertificates = HandshakeCertificates.Builder().heldCertificate(leaf).build() + MockWebServer().use { server -> + server.useHttps(serverCertificates.sslSocketFactory()) + server.enqueue(MockResponse.Builder().code(200).body("trusted").build()) + server.start(TRUST_TEST_LOOPBACK, 0) + block("https://localhost:${server.port}/status.php") + } +} + +internal fun fetchWithTrust(trust: DesktopTlsTrust, url: String): String { + val client = OkHttpClient.Builder() + .sslSocketFactory(trust.sslSocketFactory, trust.trustManager) + .retryOnConnectionFailure(false) + // One loopback route keeps the TLS failure from being masked by another address family. + .dns { hostname -> if (hostname == "localhost") listOf(TRUST_TEST_LOOPBACK) else Dns.SYSTEM.lookup(hostname) } + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + return client.newCall(Request.Builder().url(url).build()).execute().use { it.body.string() } +} + +internal fun withTrustTestDirectory(block: (Path) -> Unit) { + val directory = Files.createTempDirectory("desktop-system-trust-test") + try { + block(directory) + } finally { + File(directory.toString()).deleteRecursively() + } +} + +private val TRUST_TEST_LOOPBACK: InetAddress = + InetAddress.getByAddress("localhost", byteArrayOf(127, 0, 0, 1)) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt index cb83f676e..a7e2fd9b1 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt @@ -1,26 +1,18 @@ package dev.obiente.nextcloudnative.app -import java.net.InetAddress import java.nio.file.Files -import java.security.KeyStore import java.security.cert.CertificateException -import java.util.concurrent.TimeUnit +import java.util.concurrent.CopyOnWriteArrayList import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertSame import kotlin.test.assertTrue -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.Dns import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.tls.HandshakeCertificates import okhttp3.tls.HeldCertificate class DesktopTlsTrustTest { @@ -30,54 +22,45 @@ class DesktopTlsTrustTest { @Test fun serverIssuedByOperatingSystemTrustedPrivateCaIsAccepted() { - val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) assertIs(trust.trustManager) - withTlsServer(leafFor("localhost", privateAuthority)) { url -> - assertEquals("trusted", fetch(trust, url)) + assertEquals(DesktopSystemTrustStatus.Active(DesktopSystemTrustSource.LinuxCaBundle), trust.systemTrust) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) } } @Test fun bundledRootsRemainTrustedAlongsideOperatingSystemAnchors() { - val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) - withTlsServer(leafFor("localhost", bundledAuthority)) { url -> - assertEquals("trusted", fetch(trust, url)) + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) } } @Test fun serverIssuedByAnUntrustedCaIsRejected() { - val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) - withTlsServer(leafFor("localhost", unrelatedAuthority)) { url -> - val failure = assertFailsWith { fetch(trust, url) } - val rejected = generateSequence(failure) { it.cause } - .filterIsInstance() - .first() - // Both anchor sets were consulted and both rejected the chain. - assertEquals(1, rejected.suppressed.size) + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("localhost", unrelatedAuthority)) { url -> + val failure = assertFailsWith { fetchWithTrust(trust, url) } + // Both trust sources were consulted and both rejected the chain. + assertEquals(1, rejectedCertificate(failure).suppressed.size) } } @Test fun hostnameMismatchIsRejectedEvenWhenTheCaIsTrusted() { - val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) - withTlsServer(leafFor("nextcloud.invalid", privateAuthority)) { url -> - assertFailsWith { fetch(trust, url) } + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("nextcloud.invalid", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } } } @Test fun expiredLeafFromATrustedCaIsRejected() { - val now = System.currentTimeMillis() - val expired = HeldCertificate.Builder() - .commonName("localhost") - .addSubjectAlternativeName("localhost") - .validityInterval(now - TimeUnit.DAYS.toMillis(30), now - TimeUnit.DAYS.toMillis(1)) - .signedBy(privateAuthority) - .build() - val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) - withTlsServer(expired) { url -> - assertFailsWith { fetch(trust, url) } + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticExpiredLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } } } @@ -86,26 +69,26 @@ class DesktopTlsTrustTest { val malformed = directory.resolve("ca-certificates.crt").also { Files.writeString(it, "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n") } - val bundled = bundledOnly() + val bundled = pkixTrustManagerFor(bundledAuthority) for (anchors in listOf( loadLinuxSystemCaBundle(listOf(malformed)), loadLinuxSystemCaBundle(listOf(directory.resolve("missing.crt"))), )) { - val trust = desktopTlsTrust(anchors, bundled = bundled) - assertIs(trust.systemAnchors) + val trust = desktopTlsTrust(linuxBundleTrust(anchors), bundled = bundled) + assertIs(trust.systemTrust) assertSame(bundled, trust.trustManager) - withTlsServer(leafFor("localhost", privateAuthority)) { url -> - assertFailsWith { fetch(trust, url) } + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } } - withTlsServer(leafFor("localhost", bundledAuthority)) { url -> - assertEquals("trusted", fetch(trust, url)) + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) } } } @Test fun acceptedIssuersIncludeBothAnchorSetsForChainCleaning() { - val trust = desktopTlsTrust(systemAnchors(privateAuthority), bundled = bundledOnly()) + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) val issuers = trust.trustManager.acceptedIssuers.toSet() assertEquals(setOf(bundledAuthority.certificate, privateAuthority.certificate), issuers) trust.trustManager.acceptedIssuers[0] = unrelatedAuthority.certificate @@ -113,55 +96,83 @@ class DesktopTlsTrustTest { } @Test - fun sharedDesktopTrustBuildsOnTheTestHost() { - val client = OkHttpClient.Builder().useDesktopSystemTrust().build() - assertTrue(client.x509TrustManager != null) + fun windowsVerifierDecidesOnlyAfterTheBundledRootsRejectAChain() { + val presented = CopyOnWriteArrayList>() + val trust = desktopTlsTrust( + windowsTrust { chain -> presented.add(chain); WindowsChainVerdict.Trusted }, + bundled = pkixTrustManagerFor(bundledAuthority), + ) + assertEquals(DesktopSystemTrustStatus.Active(DesktopSystemTrustSource.WindowsChainEngine), trust.systemTrust) + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + assertTrue(presented.isEmpty()) + + val leaf = syntheticLeaf("localhost", privateAuthority) + withSyntheticTlsServer(leaf) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + assertEquals(1, presented.size) + assertContentEquals(leaf.certificate.encoded, presented.single().first()) } - private fun systemAnchors(vararg authorities: HeldCertificate) = - DesktopSystemTrustAnchors.Loaded( - DesktopSystemTrustSource.LinuxCaBundle, - authorities.map { it.certificate }, - ) + @Test + fun windowsRejectionOrNativeFailureRejectsTheServer() { + for (verdict in listOf( + WindowsChainVerdict.Rejected(trustErrorStatus = 0x20, policyError = 0x800B0109.toInt()), + WindowsChainVerdict.Failed(win32Error = 87), + )) { + val trust = desktopTlsTrust(windowsTrust { verdict }, bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + val failure = assertFailsWith { fetchWithTrust(trust, url) } + assertEquals(1, rejectedCertificate(failure).suppressed.size) + } + } + } - /** Stands in for the bundled JVM roots so tests never depend on public CAs or the network. */ - private fun bundledOnly(): X509TrustManager { - val store = KeyStore.getInstance("PKCS12").apply { - load(null, null) - setCertificateEntry("bundled", bundledAuthority.certificate) + @Test + fun windowsTrustStillLeavesHostnameVerificationToOkHttp() { + val trust = desktopTlsTrust(windowsTrust { WindowsChainVerdict.Trusted }, bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("nextcloud.invalid", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } } - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(store) - return factory.trustManagers.filterIsInstance().single() } - private fun leafFor(host: String, issuer: HeldCertificate): HeldCertificate = HeldCertificate.Builder() - .commonName(host) - .addSubjectAlternativeName(host) - .signedBy(issuer) - .build() - - private fun withTlsServer(leaf: HeldCertificate, block: (String) -> Unit) { - val serverCertificates = HandshakeCertificates.Builder().heldCertificate(leaf).build() - MockWebServer().use { server -> - server.useHttps(serverCertificates.sslSocketFactory()) - server.enqueue(MockResponse.Builder().code(200).body("trusted").build()) - server.start(LOOPBACK, 0) - block("https://localhost:${server.port}/status.php") + @Test + fun windowsTrustManagerRejectsEmptyOversizedAndClientChainsWithoutCallingWindows() { + var calls = 0 + val manager = WindowsChainEngineTrustManager { calls++; WindowsChainVerdict.Trusted } + val leaf = syntheticLeaf("localhost", privateAuthority).certificate + assertFailsWith { manager.checkServerTrusted(emptyArray(), "ECDHE_ECDSA") } + assertFailsWith { manager.checkServerTrusted(null, "ECDHE_ECDSA") } + assertFailsWith { + manager.checkServerTrusted(Array(MAX_WINDOWS_VERIFIED_CHAIN_LENGTH + 1) { leaf }, "ECDHE_ECDSA") } + assertFailsWith { manager.checkClientTrusted(arrayOf(leaf), "ECDHE_ECDSA") } + assertEquals(0, calls) + assertEquals(0, manager.acceptedIssuers.size) } - private fun fetch(trust: DesktopTlsTrust, url: String): String { - val client = OkHttpClient.Builder() - .sslSocketFactory(trust.sslSocketFactory, trust.trustManager) - .retryOnConnectionFailure(false) - // One loopback route keeps the TLS failure from being masked by another address family. - .dns { hostname -> if (hostname == "localhost") listOf(LOOPBACK) else Dns.SYSTEM.lookup(hostname) } - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .build() - return client.newCall(Request.Builder().url(url).build()).execute().use { it.body.string() } + @Test + fun unsupportedPlatformsUseOnlyTheBundledRoots() { + val system = loadDesktopSystemTrust(osName = "Mac OS X") + assertEquals(DesktopSystemTrust.Unavailable(DesktopSystemTrustUnavailableReason.UnsupportedPlatform), system) + val bundled = pkixTrustManagerFor(bundledAuthority) + assertSame(bundled, desktopTlsTrust(system, bundled = bundled).trustManager) } -} -private val LOOPBACK: InetAddress = InetAddress.getByAddress("localhost", byteArrayOf(127, 0, 0, 1)) + @Test + fun sharedDesktopTrustBuildsOnTheTestHost() { + val client = OkHttpClient.Builder().useDesktopSystemTrust().build() + assertTrue(client.x509TrustManager != null) + } + + private fun linuxAnchors(vararg authorities: HeldCertificate): DesktopSystemTrust = + linuxBundleTrust(DesktopSystemTrustAnchors.Loaded(authorities.map { it.certificate })) + + private fun windowsTrust(verifier: WindowsServerChainVerifier): DesktopSystemTrust = + DesktopSystemTrust.Available(DesktopSystemTrustSource.WindowsChainEngine, WindowsChainEngineTrustManager(verifier)) + + private fun rejectedCertificate(failure: Throwable): CertificateException = + generateSequence(failure) { it.cause }.filterIsInstance().first() +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt new file mode 100644 index 000000000..0dddcdf2a --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt @@ -0,0 +1,180 @@ +package dev.obiente.nextcloudnative.app + +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.ptr.PointerByReference +import com.sun.jna.win32.StdCallLibrary +import java.lang.ref.Reference +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import okhttp3.tls.HeldCertificate + +/** + * Exercises the real Windows chain engine on a Windows test host. Synthetic roots live only in an + * in-memory exclusive-root engine, so no user or machine certificate store is read for trust or + * modified. Other hosts return early; the policy mapping is covered by [DesktopTlsTrustTest]. + */ +class WindowsChainEngineTrustTest { + private val privateAuthority = syntheticTrustAuthority("Synthetic Windows private CA") + private val unrelatedAuthority = syntheticTrustAuthority("Synthetic Windows unrelated CA") + + @Test + fun chainToAnEngineTrustedRootIsAccepted() = withExclusiveRootEngine(privateAuthority) { verifier -> + val leaf = syntheticLeaf("localhost", privateAuthority) + assertEquals(WindowsChainVerdict.Trusted, verifier.verify(listOf(leaf.certificate.encoded))) + } + + @Test + fun chainToAnotherRootIsRejected() = withExclusiveRootEngine(privateAuthority) { verifier -> + val leaf = syntheticLeaf("localhost", unrelatedAuthority) + assertIs(verifier.verify(listOf(leaf.certificate.encoded))) + } + + @Test + fun expiredLeafIsRejected() = withExclusiveRootEngine(privateAuthority) { verifier -> + val leaf = syntheticExpiredLeaf("localhost", privateAuthority) + val verdict = assertIs(verifier.verify(listOf(leaf.certificate.encoded))) + assertTrue(verdict.trustErrorStatus and CERT_TRUST_IS_NOT_TIME_VALID != 0) + } + + @Test + fun rootWhoseStorePurposeExcludesServerAuthenticationIsRejected() = + withExclusiveRootEngine(privateAuthority, rootPurpose = CODE_SIGNING_ONLY_EKU) { verifier -> + val leaf = syntheticLeaf("localhost", privateAuthority) + val verdict = assertIs(verifier.verify(listOf(leaf.certificate.encoded))) + assertTrue(verdict.trustErrorStatus and CERT_TRUST_IS_NOT_VALID_FOR_USAGE != 0) + } + + @Test + fun currentUserEngineDoesNotTrustAnUninstalledSyntheticRoot() { + if (!isWindowsHost()) return + val leaf = syntheticLeaf("localhost", privateAuthority) + assertIs(JnaWindowsServerChainVerifier().verify(listOf(leaf.certificate.encoded))) + } + + @Test + fun handshakeUsesTheWindowsDecisionAndKeepsHostnameVerification() = + withExclusiveRootEngine(privateAuthority) { verifier -> + val trust = desktopTlsTrust( + DesktopSystemTrust.Available( + DesktopSystemTrustSource.WindowsChainEngine, + WindowsChainEngineTrustManager(verifier), + ), + bundled = pkixTrustManagerFor(unrelatedAuthority), + ) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + withSyntheticTlsServer(syntheticLeaf("nextcloud.invalid", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + withSyntheticTlsServer(syntheticExpiredLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + } + + private fun withExclusiveRootEngine( + root: HeldCertificate, + rootPurpose: ByteArray? = null, + block: (WindowsServerChainVerifier) -> Unit, + ) { + if (!isWindowsHost()) return + val crypt32 = WindowsCrypt32.instance + val testCrypt32 = TestCrypt32.instance + val rootStore = requireNotNull(crypt32.CertOpenStore(CERT_STORE_PROV_MEMORY, 0, null, 0, null)) + try { + val encoded = root.certificate.encoded + val rootContext = PointerByReference() + check(crypt32.CertAddEncodedCertificateToStore( + rootStore, X509_ASN_ENCODING, encoded, encoded.size, CERT_STORE_ADD_ALWAYS, rootContext, + )) + try { + if (rootPurpose != null) setEnhancedKeyUsage(testCrypt32, rootContext.value, rootPurpose) + } finally { + crypt32.CertFreeCertificateContext(rootContext.value) + } + val config = ChainEngineConfig().apply { + cbSize = size() + hExclusiveRoot = rootStore + } + val engine = PointerByReference() + check(testCrypt32.CertCreateCertificateChainEngine(config, engine)) { + "CertCreateCertificateChainEngine failed: ${Native.getLastError()}" + } + try { + block(JnaWindowsServerChainVerifier(chainEngine = engine.value)) + } finally { + testCrypt32.CertFreeCertificateChainEngine(engine.value) + } + } finally { + crypt32.CertCloseStore(rootStore, 0) + } + } + + private fun setEnhancedKeyUsage(crypt32: TestCrypt32, context: Pointer, encodedUsage: ByteArray) { + val data = Memory(encodedUsage.size.toLong()).apply { write(0, encodedUsage, 0, encodedUsage.size) } + // CRYPT_DATA_BLOB: DWORD cbData, then a pointer aligned to the pointer size. + val blob = Memory(2L * Native.POINTER_SIZE).apply { + clear() + setInt(0, encodedUsage.size) + setPointer(Native.POINTER_SIZE.toLong(), data) + } + check(crypt32.CertSetCertificateContextProperty(context, CERT_ENHKEY_USAGE_PROP_ID, 0, blob)) { + "CertSetCertificateContextProperty failed: ${Native.getLastError()}" + } + Reference.reachabilityFence(data) + } + + @Structure.FieldOrder( + "cbSize", "hRestrictedRoot", "hRestrictedTrust", "hRestrictedOther", "cAdditionalStore", + "rghAdditionalStore", "dwFlags", "dwUrlRetrievalTimeout", "MaximumCachedCertificates", + "CycleDetectionModulus", "hExclusiveRoot", "hExclusiveTrustedPeople", "dwExclusiveFlags", + ) + class ChainEngineConfig : Structure() { + @JvmField var cbSize: Int = 0 + @JvmField var hRestrictedRoot: Pointer? = null + @JvmField var hRestrictedTrust: Pointer? = null + @JvmField var hRestrictedOther: Pointer? = null + @JvmField var cAdditionalStore: Int = 0 + @JvmField var rghAdditionalStore: Pointer? = null + @JvmField var dwFlags: Int = 0 + @JvmField var dwUrlRetrievalTimeout: Int = 0 + @JvmField var MaximumCachedCertificates: Int = 0 + @JvmField var CycleDetectionModulus: Int = 0 + @JvmField var hExclusiveRoot: Pointer? = null + @JvmField var hExclusiveTrustedPeople: Pointer? = null + @JvmField var dwExclusiveFlags: Int = 0 + } + + @Suppress("FunctionName") + interface TestCrypt32 : StdCallLibrary { + fun CertCreateCertificateChainEngine(config: ChainEngineConfig, engine: PointerByReference): Boolean + + fun CertFreeCertificateChainEngine(engine: Pointer) + + fun CertSetCertificateContextProperty(context: Pointer, propertyId: Int, flags: Int, data: Pointer): Boolean + + companion object { + val instance: TestCrypt32 by lazy { Native.load("crypt32", TestCrypt32::class.java) } + } + } + + private fun isWindowsHost(): Boolean = + System.getProperty("os.name").orEmpty().startsWith("Windows", ignoreCase = true) +} + +private const val CERT_ENHKEY_USAGE_PROP_ID = 9 +private const val CERT_TRUST_IS_NOT_TIME_VALID = 0x00000001 +private const val CERT_TRUST_IS_NOT_VALID_FOR_USAGE = 0x00000010 + +/** DER EnhancedKeyUsage SEQUENCE containing only id-kp-codeSigning (1.3.6.1.5.5.7.3.3). */ +private val CODE_SIGNING_ONLY_EKU = byteArrayOf( + 0x30, 0x0A, 0x06, 0x08, 0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x03, +) From 95e9fbffbd182267d4b5fc0883a8ca9a0f2a9941 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 19:18:53 +0200 Subject: [PATCH 4/8] fix(desktop): reject orphan certificate terminators in system CA bundles --- .../obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt | 2 ++ .../nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt index d0a30326e..b78e13a10 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt @@ -133,6 +133,8 @@ internal fun parsePemTrustBundle(text: String): DesktopSystemTrustAnchors { else -> block.append(line) } line == PEM_CERTIFICATE_BEGIN -> block = StringBuilder() + // A terminator without its header means the bundle was cut or spliced. + line == PEM_CERTIFICATE_END -> return unavailable(DesktopSystemTrustUnavailableReason.Malformed) line.startsWith("-----BEGIN ") && line.endsWith("-----") -> skippedLabel = line.removePrefix("-----BEGIN ").removeSuffix("-----") } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt index 999da9037..147099f09 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt @@ -48,6 +48,8 @@ class DesktopSystemTrustAnchorsTest { "nested header" to "-----BEGIN CERTIFICATE-----\n$body\n-----BEGIN CERTIFICATE-----\n", "trailing DER bytes" to "-----BEGIN CERTIFICATE-----\n$trailingBytes\n-----END CERTIFICATE-----\n", "unterminated other label" to "$valid-----BEGIN TRUSTED CERTIFICATE-----\n$body\n", + "orphan terminator after a valid block" to "$valid-----END CERTIFICATE-----\n", + "orphan terminator before a valid block" to "$body\n-----END CERTIFICATE-----\n$valid", ) for ((name, bundle) in malformedBundles) { assertEquals( From 1cadcaa5ced6788fa9ac8885a9fc79b807f01e5c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 19:18:53 +0200 Subject: [PATCH 5/8] docs(website): date the private certificate authority guidance separately --- website/content/guides/desktop-getting-started.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/website/content/guides/desktop-getting-started.md b/website/content/guides/desktop-getting-started.md index 049598612..e1346e1d2 100644 --- a/website/content/guides/desktop-getting-started.md +++ b/website/content/guides/desktop-getting-started.md @@ -33,7 +33,13 @@ Download the exact package attached to a GitHub prerelease: DEB or RPM for the m Open nati.ve, enter the complete `https://` server address, and finish sign-in on the server's Login Flow page. The app stores the generated app password in Linux Secret Service or Windows Credential Manager. Home should then show the account status. A macOS package must not be used for an account until supported Keychain login is implemented. -If the server uses a private certificate authority, never work around a certificate warning. In builds containing operating-system certificate trust, add the authority to the Linux system CA bundle with your distribution's tool or to the Windows Trusted Root Certification Authorities store, then restart nati.ve. Check the [release notes](https://github.com/Obiente/native/releases) for the behavior included in your build, and see [sign-in diagnostics](https://github.com/obiente/native/blob/main/docs/login-diagnostics.md#server-certificates-and-private-certificate-authorities) for the exact bundle locations. +### Servers that use a private certificate authority + +**Last reviewed: 2026-10-08.** The following behavior is implemented in source; +availability in installed builds may change. Check the +[release notes](https://github.com/Obiente/native/releases) for your version. + +Never work around a certificate warning. In builds containing operating-system certificate trust, add the authority to the Linux system CA bundle with your distribution's tool, or to the Windows Trusted Root Certification Authorities store, then restart nati.ve. Windows also applies its own distrust and certificate-purpose settings. See [sign-in diagnostics](https://github.com/obiente/native/blob/main/docs/login-diagnostics.md#server-certificates-and-private-certificate-authorities) for the exact Linux bundle locations and limits. ## 2. Use desktop navigation and installed app workspaces From aeee5da8d3973cf3b99126663ad383f74d104655 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:39:53 +0000 Subject: [PATCH 6/8] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 6aab32dc4..4afe078c6 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -508,7 +508,7 @@ "gradle/wrapper/gradle-wrapper.properties": "aef287d114ce3153c3d535697a61928f5034990d570cbb2e93df549e9671483d", "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "0092b8bc6445328384db348a4fd5eb173c5fe4133d170a193fa7c399dcd411ab", - "ui/build.gradle.kts": "e437f2e8fb45b2042937dd3038b1b0c6135595c6c2e47f1be207c899a3a558b3", + "ui/build.gradle.kts": "191280b7fea632718455742a59c24808d43d3e7725d2fd6d93c4d1cc9ce20b6f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountHomeWorkspaceStorage.kt": "5e0fa144df65840d15882c7da1dac19041083a302ced40578d07747f8ec7d828", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPlaybackRetirement.kt": "01069d5a599bbf9dce5c9a152bb771f4bd524de7fb2d74db9c6bf9b774b31cb2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "d247d7d310e8579717e243bce827e8ab6d8a8e5a6df011b481e9bc6fc72da862", From 7eb2fdfd271dd53c49e1e7beab5f9762a10ae26c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 9 Oct 2026 17:30:47 +0200 Subject: [PATCH 7/8] fix(desktop): let explicit Windows distrust veto bundled acceptance A chain anchored in the bundled Java roots was accepted without asking Windows, so a certificate in the Disallowed store or restricted there to a non-server purpose stayed trusted whenever Java also trusted it. After the bundled roots accept a chain on Windows, the cached-only chain engine check runs as a veto. Explicit distrust, revocation Windows already knows, or a stored purpose that excludes server authentication rejects the chain. An unknown or untrusted Windows root, other rejections, and failed native calls keep the bundled decision, so stale Windows root stores cannot break public servers. --- PLATFORMS.md | 11 ++- docs/login-diagnostics.md | 7 +- .../nextcloudnative/app/DesktopTlsTrust.kt | 53 +++++++++--- .../app/WindowsChainEngineTrust.kt | 40 +++++++++ .../app/WindowsChainEngineTrustTest.kt | 30 ++++++- .../app/WindowsExplicitDistrustVetoTest.kt | 86 +++++++++++++++++++ 6 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt diff --git a/PLATFORMS.md b/PLATFORMS.md index 02764f686..598216447 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -361,15 +361,24 @@ guarantees, not claims about a published installer; the code may have changed. roots are not copied into Java, so Windows store policy such as the Disallowed store and certificate purpose restrictions stays in force. Chain building uses cached data only and does not fetch AIA or root updates. + After the bundled roots accept a chain, the same Windows check runs once + more as a veto: explicit distrust, revocation already known to Windows, or + a stored purpose that excludes server authentication rejects the chain. An + untrusted or unknown Windows root, other rejections, and failed native calls + keep the bundled decision, so a stale Windows root store cannot break public + servers. This adds one cached chain build to each new TLS connection. - macOS reads no system trust yet. Update checks and project news keep the bundled roots only. - `DesktopTlsTrustTest` and `DesktopSystemTrustAnchorsTest` cover synthetic CAs, untrusted and expired chains, hostname mismatch, malformed or missing bundles, and the Windows verdict mapping through an injected verifier. + `WindowsExplicitDistrustVetoTest` covers vetoing and non-vetoing verdicts + for chains the bundled roots accept. `WindowsChainEngineTrustTest` runs only on Windows hosts and drives the real chain engine with an in-memory exclusive root: a trusted root, an unrelated root, an expired leaf, and a root whose stored purpose excludes server - authentication. No test modifies a Windows certificate store, so Disallowed + authentication, including that restricted root vetoing a chain the bundled + roots also trust. No test modifies a Windows certificate store, so Disallowed handling relies on documented Windows engine behavior. No test establishes behavior inside a Flatpak or other sandbox. - User-facing guidance is in diff --git a/docs/login-diagnostics.md b/docs/login-diagnostics.md index 2796343fa..53b552e4f 100644 --- a/docs/login-diagnostics.md +++ b/docs/login-diagnostics.md @@ -69,7 +69,12 @@ the operating system's trust decision: purpose restrictions set on stored certificates. Chain building uses only locally available data: it does not download missing intermediate certificates or root updates, so the server must send its intermediates. - Restart nati.ve after adding a root. + Restart nati.ve after adding a root. Windows can also veto a certificate + that the bundled Java roots accept: a certificate in the Disallowed store, + a revoked certificate Windows already knows about, or one whose stored + purpose excludes server authentication is rejected. A root that Windows + merely does not have, or a failure of the Windows check itself, does not + block a certificate the bundled roots accept. - **macOS** desktop does not yet read the Keychain; it uses only the bundled Java roots. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt index 15d989be6..3a73481e2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt @@ -40,16 +40,29 @@ internal sealed interface DesktopSystemTrustStatus { data class Unavailable(val reason: DesktopSystemTrustUnavailableReason) : DesktopSystemTrustStatus } -/** Operating-system trust consulted only after the bundled roots reject a server chain. */ +/** + * Operating-system trust. [Available.trustManager] is consulted only after the bundled roots reject + * a server chain. [Available.veto], when present, is consulted after the bundled roots accept one. + */ internal sealed interface DesktopSystemTrust { data class Available( val source: DesktopSystemTrustSource, val trustManager: X509ExtendedTrustManager, + val veto: DesktopSystemTrustVeto? = null, ) : DesktopSystemTrust data class Unavailable(val reason: DesktopSystemTrustUnavailableReason) : DesktopSystemTrust } +/** + * Explicit operating-system distrust of a chain the bundled roots accepted. It returns a failure + * only for negative policy such as an explicitly distrusted or purpose-restricted certificate, + * never because the operating system merely does not know the root. + */ +internal fun interface DesktopSystemTrustVeto { + fun explicitDistrust(chain: Array): CertificateException? +} + /** Applies the process-wide desktop trust. Never use it to relax validation for one request. */ internal fun OkHttpClient.Builder.useDesktopSystemTrust(): OkHttpClient.Builder { val trust = sharedDesktopTlsTrust @@ -92,7 +105,7 @@ internal fun desktopTlsTrust( bundled to DesktopSystemTrustStatus.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) else -> { systemTrust as DesktopSystemTrust.Available - DesktopCompositeTrustManager(extendedBundled, systemTrust.trustManager) to + DesktopCompositeTrustManager(extendedBundled, systemTrust.trustManager, systemTrust.veto) to DesktopSystemTrustStatus.Active(systemTrust.source) } } @@ -142,11 +155,13 @@ private fun trustManagerFor(store: KeyStore?): X509TrustManager? { * Accepts a server chain when the bundled roots or the operating-system trust validate it. The * bundled delegate is a full JSSE PKIX trust manager. The system delegate is either a PKIX manager * limited to Linux bundle anchors or the Windows chain engine with its own store policy. When both - * reject the chain, the bundled failure is rethrown with the system failure suppressed. + * reject the chain, the bundled failure is rethrown with the system failure suppressed. A chain the + * bundled roots accept is still rejected when the optional [veto] reports explicit OS distrust. */ internal class DesktopCompositeTrustManager( private val bundled: X509ExtendedTrustManager, private val system: X509ExtendedTrustManager, + private val veto: DesktopSystemTrustVeto? = null, ) : X509ExtendedTrustManager() { private val acceptedIssuers: Array = (bundled.acceptedIssuers.asSequence() + system.acceptedIssuers.asSequence()) @@ -156,18 +171,21 @@ internal class DesktopCompositeTrustManager( override fun checkServerTrusted(chain: Array?, authType: String?) = firstAccepting( + chain, { bundled.checkServerTrusted(chain, authType) }, { system.checkServerTrusted(chain, authType) }, ) override fun checkServerTrusted(chain: Array?, authType: String?, socket: Socket?) = firstAccepting( + chain, { bundled.checkServerTrusted(chain, authType, socket) }, { system.checkServerTrusted(chain, authType, socket) }, ) override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = firstAccepting( + chain, { bundled.checkServerTrusted(chain, authType, engine) }, { system.checkServerTrusted(chain, authType, engine) }, ) @@ -184,16 +202,27 @@ internal class DesktopCompositeTrustManager( override fun getAcceptedIssuers(): Array = acceptedIssuers.copyOf() - private inline fun firstAccepting(bundledCheck: () -> Unit, systemCheck: () -> Unit) { - try { + private inline fun firstAccepting( + chain: Array?, + bundledCheck: () -> Unit, + systemCheck: () -> Unit, + ) { + val bundledFailure = try { bundledCheck() - } catch (bundledFailure: CertificateException) { - try { - systemCheck() - } catch (systemFailure: CertificateException) { - bundledFailure.addSuppressed(systemFailure) - throw bundledFailure - } + null + } catch (failure: CertificateException) { + failure + } + if (bundledFailure == null) { + // The bundled roots accepted a non-empty chain; explicit OS distrust still wins. + chain?.let { veto?.explicitDistrust(it) }?.let { throw it } + return + } + try { + systemCheck() + } catch (systemFailure: CertificateException) { + bundledFailure.addSuppressed(systemFailure) + throw bundledFailure } } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt index e995387e1..2268ffe2c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt @@ -84,6 +84,35 @@ internal class WindowsChainEngineTrustManager( throw CertificateException("Windows operating-system trust is used only for server certificates.") } +/** + * Lets explicit Windows policy reject a chain the bundled roots accepted: an explicitly distrusted + * (Disallowed) or revoked certificate, or a certificate whose stored purpose excludes server + * authentication. An unknown or untrusted Windows root, any other rejection, and a failed native + * call do not veto, so behavior falls back to the bundled roots alone. Uses cached data only. + */ +internal class WindowsExplicitDistrustVeto( + private val verifier: WindowsServerChainVerifier, +) : DesktopSystemTrustVeto { + override fun explicitDistrust(chain: Array): CertificateException? { + if (chain.isEmpty() || chain.size > MAX_WINDOWS_VERIFIED_CHAIN_LENGTH) return null + val encoded = try { + chain.map { it.encoded } + } catch (_: CertificateEncodingException) { + return null + } + val verdict = verifier.verify(encoded) as? WindowsChainVerdict.Rejected ?: return null + if (!verdict.isExplicitWindowsDistrust()) return null + return CertificateException( + "Windows policy explicitly distrusts the server certificate chain (trust 0x%08X, policy 0x%08X)." + .format(verdict.trustErrorStatus, verdict.policyError), + ) + } +} + +internal fun WindowsChainVerdict.Rejected.isExplicitWindowsDistrust(): Boolean = + trustErrorStatus and WINDOWS_EXPLICIT_DISTRUST_TRUST_BITS != 0 || + policyError in WINDOWS_EXPLICIT_DISTRUST_POLICY_ERRORS + /** Loads the Windows chain engine trust, or reports why it is unavailable. */ internal fun windowsChainEngineTrust(): DesktopSystemTrust { val verifier = try { @@ -94,6 +123,7 @@ internal fun windowsChainEngineTrust(): DesktopSystemTrust { return DesktopSystemTrust.Available( DesktopSystemTrustSource.WindowsChainEngine, WindowsChainEngineTrustManager(verifier), + WindowsExplicitDistrustVeto(verifier), ) } @@ -265,3 +295,13 @@ private const val CHAIN_FLAGS = CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL or CERT_CHAIN_DISABLE_MD2_MD4 or CERT_CHAIN_DISABLE_AIA internal const val MAX_WINDOWS_VERIFIED_CHAIN_LENGTH = 16 +internal const val CERT_TRUST_IS_REVOKED = 0x00000004 +internal const val CERT_TRUST_IS_NOT_VALID_FOR_USAGE = 0x00000010 +internal const val CERT_TRUST_IS_EXPLICIT_DISTRUST = 0x04000000 +private const val WINDOWS_EXPLICIT_DISTRUST_TRUST_BITS = + CERT_TRUST_IS_REVOKED or CERT_TRUST_IS_NOT_VALID_FOR_USAGE or CERT_TRUST_IS_EXPLICIT_DISTRUST +private val WINDOWS_EXPLICIT_DISTRUST_POLICY_ERRORS = setOf( + 0x800B010C.toInt(), // CERT_E_REVOKED + 0x800B0110.toInt(), // CERT_E_WRONG_USAGE + 0x800B0111.toInt(), // TRUST_E_EXPLICIT_DISTRUST +) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt index 0dddcdf2a..89f9183c3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt @@ -80,6 +80,35 @@ class WindowsChainEngineTrustTest { } } + @Test + fun windowsPurposeRestrictionVetoesAChainTheBundledRootsAccept() = + withExclusiveRootEngine(privateAuthority, rootPurpose = CODE_SIGNING_ONLY_EKU) { verifier -> + // The same anchor is trusted by the bundled roots and restricted to code signing in Windows. + val trust = windowsTrustWithVeto(verifier, bundled = privateAuthority) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + } + + @Test + fun rootUnknownToWindowsDoesNotVetoBundledAcceptance() = + withExclusiveRootEngine(unrelatedAuthority) { verifier -> + val trust = windowsTrustWithVeto(verifier, bundled = privateAuthority) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + } + + private fun windowsTrustWithVeto(verifier: WindowsServerChainVerifier, bundled: HeldCertificate) = + desktopTlsTrust( + DesktopSystemTrust.Available( + DesktopSystemTrustSource.WindowsChainEngine, + WindowsChainEngineTrustManager(verifier), + WindowsExplicitDistrustVeto(verifier), + ), + bundled = pkixTrustManagerFor(bundled), + ) + private fun withExclusiveRootEngine( root: HeldCertificate, rootPurpose: ByteArray? = null, @@ -172,7 +201,6 @@ class WindowsChainEngineTrustTest { private const val CERT_ENHKEY_USAGE_PROP_ID = 9 private const val CERT_TRUST_IS_NOT_TIME_VALID = 0x00000001 -private const val CERT_TRUST_IS_NOT_VALID_FOR_USAGE = 0x00000010 /** DER EnhancedKeyUsage SEQUENCE containing only id-kp-codeSigning (1.3.6.1.5.5.7.3.3). */ private val CODE_SIGNING_ONLY_EKU = byteArrayOf( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt new file mode 100644 index 000000000..87941ed92 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt @@ -0,0 +1,86 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.SSLHandshakeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A chain the bundled roots accept is still rejected when Windows explicitly distrusts it, but an + * unknown Windows root or a failed native call keeps the bundled-only decision. + */ +class WindowsExplicitDistrustVetoTest { + private val bundledAuthority = syntheticTrustAuthority("Synthetic bundled CA") + private val privateAuthority = syntheticTrustAuthority("Synthetic private CA") + + @Test + fun explicitWindowsDistrustOverridesBundledAcceptance() { + for (verdict in listOf( + WindowsChainVerdict.Rejected(CERT_TRUST_IS_EXPLICIT_DISTRUST, TRUST_E_EXPLICIT_DISTRUST), + WindowsChainVerdict.Rejected(CERT_TRUST_IS_NOT_VALID_FOR_USAGE, CERT_E_WRONG_USAGE), + WindowsChainVerdict.Rejected(CERT_TRUST_IS_REVOKED, CERT_E_REVOKED), + WindowsChainVerdict.Rejected(trustErrorStatus = 0, policyError = CERT_E_WRONG_USAGE), + )) { + val trust = trustWithWindowsVerdict { verdict } + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + val failure = assertFailsWith(verdict.toString()) { fetchWithTrust(trust, url) } + assertTrue(generateSequence(failure) { it.cause }.any { "explicitly distrusts" in it.message.orEmpty() }) + } + } + } + + @Test + fun unknownRootTrustedVerdictAndNativeFailureKeepBundledAcceptance() { + for (verdict in listOf( + WindowsChainVerdict.Rejected(CERT_TRUST_IS_UNTRUSTED_ROOT, CERT_E_UNTRUSTEDROOT), + WindowsChainVerdict.Rejected(CERT_TRUST_IS_PARTIAL_CHAIN, CERT_E_CHAINING), + WindowsChainVerdict.Failed(win32Error = 87), + WindowsChainVerdict.Trusted, + )) { + val calls = AtomicInteger() + val trust = trustWithWindowsVerdict { calls.incrementAndGet(); verdict } + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url), verdict.toString()) + } + assertEquals(1, calls.get(), "The veto consults Windows once per bundled acceptance.") + } + } + + @Test + fun vetoDoesNotTurnABundledRejectionIntoAcceptance() { + val trust = trustWithWindowsVerdict { WindowsChainVerdict.Rejected(CERT_TRUST_IS_UNTRUSTED_ROOT, CERT_E_UNTRUSTEDROOT) } + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + } + + @Test + fun vetoIgnoresChainsItCannotEvaluate() { + var calls = 0 + val veto = WindowsExplicitDistrustVeto { calls++; WindowsChainVerdict.Rejected(CERT_TRUST_IS_EXPLICIT_DISTRUST, 0) } + val leaf = syntheticLeaf("localhost", bundledAuthority).certificate + assertNull(veto.explicitDistrust(emptyArray())) + assertNull(veto.explicitDistrust(Array(MAX_WINDOWS_VERIFIED_CHAIN_LENGTH + 1) { leaf })) + assertEquals(0, calls) + } + + private fun trustWithWindowsVerdict(verifier: WindowsServerChainVerifier): DesktopTlsTrust = desktopTlsTrust( + DesktopSystemTrust.Available( + DesktopSystemTrustSource.WindowsChainEngine, + WindowsChainEngineTrustManager(verifier), + WindowsExplicitDistrustVeto(verifier), + ), + bundled = pkixTrustManagerFor(bundledAuthority), + ) +} + +private const val CERT_TRUST_IS_UNTRUSTED_ROOT = 0x00000020 +private const val CERT_TRUST_IS_PARTIAL_CHAIN = 0x00010000 +private val CERT_E_UNTRUSTEDROOT = 0x800B0109.toInt() +private val CERT_E_CHAINING = 0x800B010A.toInt() +private val CERT_E_REVOKED = 0x800B010C.toInt() +private val CERT_E_WRONG_USAGE = 0x800B0110.toInt() +private val TRUST_E_EXPLICIT_DISTRUST = 0x800B0111.toInt() From ff14b37dd40d1aa6d6088a865370ba669ee28391 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 12 Oct 2026 21:51:49 +0200 Subject: [PATCH 8/8] fix(desktop): check cached revocation in the Windows chain engine CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL does not enable revocation checking, so the Windows check could never report a revoked certificate and the distrust veto could not act on one. Chain building now also requests end-certificate revocation with CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, so only CRL or OCSP data Windows already cached is used and nothing is downloaded during a handshake. Unknown or offline revocation status is ignored in the SSL policy and in the verdict, so chains without cached revocation data keep their previous result. A definite cached revocation rejects the chain on the Windows trust path and vetoes bundled acceptance. --- PLATFORMS.md | 28 ++++++---- docs/login-diagnostics.md | 12 +++-- .../app/WindowsChainEngineTrust.kt | 52 +++++++++++++++---- .../app/WindowsExplicitDistrustVetoTest.kt | 51 ++++++++++++++++++ 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/PLATFORMS.md b/PLATFORMS.md index 598216447..9159f1dd7 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -361,26 +361,36 @@ guarantees, not claims about a published installer; the code may have changed. roots are not copied into Java, so Windows store policy such as the Disallowed store and certificate purpose restrictions stays in force. Chain building uses cached data only and does not fetch AIA or root updates. + End-certificate revocation is requested with + `CERT_CHAIN_REVOCATION_CHECK_END_CERT` and + `CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY`, because + `CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL` does not cover revocation. Unknown or + offline revocation status is ignored, so only a revocation already present + in the Windows cache can reject a chain. After the bundled roots accept a chain, the same Windows check runs once - more as a veto: explicit distrust, revocation already known to Windows, or - a stored purpose that excludes server authentication rejects the chain. An - untrusted or unknown Windows root, other rejections, and failed native calls - keep the bundled decision, so a stale Windows root store cannot break public - servers. This adds one cached chain build to each new TLS connection. + more as a veto: explicit distrust, a cached revocation of the server + certificate, or a stored purpose that excludes server authentication + rejects the chain. An untrusted or unknown Windows root, unknown revocation + status, other rejections, and failed native calls keep the bundled decision, + so a stale Windows root store cannot break public servers. This adds one + cached chain build to each new TLS connection. - macOS reads no system trust yet. Update checks and project news keep the bundled roots only. - `DesktopTlsTrustTest` and `DesktopSystemTrustAnchorsTest` cover synthetic CAs, untrusted and expired chains, hostname mismatch, malformed or missing bundles, and the Windows verdict mapping through an injected verifier. `WindowsExplicitDistrustVetoTest` covers vetoing and non-vetoing verdicts - for chains the bundled roots accept. + for chains the bundled roots accept, including revoked, unknown, and + offline revocation status. `WindowsChainEngineTrustTest` runs only on Windows hosts and drives the real chain engine with an in-memory exclusive root: a trusted root, an unrelated root, an expired leaf, and a root whose stored purpose excludes server authentication, including that restricted root vetoing a chain the bundled - roots also trust. No test modifies a Windows certificate store, so Disallowed - handling relies on documented Windows engine behavior. No test establishes - behavior inside a Flatpak or other sandbox. + roots also trust. They also confirm that synthetic certificates without + revocation data stay trusted with revocation checking enabled. No test + modifies a Windows certificate store or revocation cache, so Disallowed + handling and a definite cached revocation rely on documented Windows engine + behavior. No test establishes behavior inside a Flatpak or other sandbox. - User-facing guidance is in [sign-in diagnostics](docs/login-diagnostics.md#server-certificates-and-private-certificate-authorities). diff --git a/docs/login-diagnostics.md b/docs/login-diagnostics.md index 53b552e4f..589fc3e79 100644 --- a/docs/login-diagnostics.md +++ b/docs/login-diagnostics.md @@ -71,10 +71,14 @@ the operating system's trust decision: certificates or root updates, so the server must send its intermediates. Restart nati.ve after adding a root. Windows can also veto a certificate that the bundled Java roots accept: a certificate in the Disallowed store, - a revoked certificate Windows already knows about, or one whose stored - purpose excludes server authentication is rejected. A root that Windows - merely does not have, or a failure of the Windows check itself, does not - block a certificate the bundled roots accept. + a server certificate that cached revocation data already marks as revoked, + or one whose stored purpose excludes server authentication is rejected. + Revocation is checked only for the server certificate and only against CRL + or OCSP data Windows has already cached; nati.ve never downloads revocation + data during a connection, so a revocation Windows has not cached is not + detected. Unknown or offline revocation status, a root that Windows merely + does not have, or a failure of the Windows check itself does not block a + certificate the bundled roots accept. - **macOS** desktop does not yet read the Keychain; it uses only the bundled Java roots. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt index 2268ffe2c..c211dd55f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt @@ -168,6 +168,8 @@ internal class JnaWindowsServerChainVerifier( RequestedUsage.dwType = USAGE_MATCH_TYPE_AND RequestedUsage.Usage.cUsageIdentifier = 1 RequestedUsage.Usage.rgpszUsageIdentifier = usageArray + // Revocation is cache-only; this bounds any retrieval Windows still attempts. + dwUrlRetrievalTimeout = WINDOWS_REVOCATION_TIMEOUT_MILLIS } try { val chainReference = PointerByReference() @@ -201,7 +203,8 @@ internal class JnaWindowsServerChainVerifier( } val policyPara = WinCrypt.CERT_CHAIN_POLICY_PARA().apply { cbSize = size() - dwFlags = 0 + // Cached revocation data is often absent; only a definite revocation may reject. + dwFlags = CERT_CHAIN_POLICY_IGNORE_ALL_REV_UNKNOWN_FLAGS pvExtraPolicyPara = sslPara.pointer } val policyStatus = WinCrypt.CERT_CHAIN_POLICY_STATUS().apply { cbSize = size() } @@ -213,12 +216,22 @@ internal class JnaWindowsServerChainVerifier( Reference.reachabilityFence(sslPara) } // CERT_CHAIN_CONTEXT starts with cbSize followed by CERT_TRUST_STATUS.dwErrorStatus. - val trustErrorStatus = chain.getInt(4) - return if (trustErrorStatus == 0 && policyStatus.dwError == 0) { - WindowsChainVerdict.Trusted - } else { - WindowsChainVerdict.Rejected(trustErrorStatus, policyStatus.dwError) - } + return windowsChainVerdict(trustErrorStatus = chain.getInt(4), policyError = policyStatus.dwError) + } +} + +/** + * Classifies one evaluated chain. Unknown or offline revocation status never rejects a chain by + * itself, because cache-only checking often has no revocation data. A definite revocation, and + * every other trust or policy error, rejects it. + */ +internal fun windowsChainVerdict(trustErrorStatus: Int, policyError: Int): WindowsChainVerdict { + val definiteErrors = trustErrorStatus and WINDOWS_UNKNOWN_REVOCATION_TRUST_BITS.inv() + val policyAccepts = policyError == 0 || policyError in WINDOWS_UNKNOWN_REVOCATION_POLICY_ERRORS + return if (definiteErrors == 0 && policyAccepts) { + WindowsChainVerdict.Trusted + } else { + WindowsChainVerdict.Rejected(trustErrorStatus, policyError) } } @@ -290,10 +303,24 @@ private const val CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL = 0x00000004 private const val CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE = 0x00000100 private const val CERT_CHAIN_DISABLE_MD2_MD4 = 0x00001000 private const val CERT_CHAIN_DISABLE_AIA = 0x00002000 -private const val CHAIN_FLAGS = CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL or +private const val CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT = 0x08000000 +private const val CERT_CHAIN_REVOCATION_CHECK_END_CERT = 0x10000000 +private val CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY = 0x80000000.toInt() +private const val CERT_CHAIN_POLICY_IGNORE_ALL_REV_UNKNOWN_FLAGS = 0x00000F00 +private const val WINDOWS_REVOCATION_TIMEOUT_MILLIS = 1_000 + +/** + * Same flags for the trust fallback and the distrust veto. CACHE_ONLY_URL_RETRIEVAL does not cover + * revocation, so end-certificate revocation is requested separately and limited to cached CRL or + * OCSP data. Nothing is downloaded during a handshake. + */ +private val CHAIN_FLAGS = CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL or CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE or CERT_CHAIN_DISABLE_MD2_MD4 or - CERT_CHAIN_DISABLE_AIA + CERT_CHAIN_DISABLE_AIA or + CERT_CHAIN_REVOCATION_CHECK_END_CERT or + CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY or + CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT internal const val MAX_WINDOWS_VERIFIED_CHAIN_LENGTH = 16 internal const val CERT_TRUST_IS_REVOKED = 0x00000004 internal const val CERT_TRUST_IS_NOT_VALID_FOR_USAGE = 0x00000010 @@ -305,3 +332,10 @@ private val WINDOWS_EXPLICIT_DISTRUST_POLICY_ERRORS = setOf( 0x800B0110.toInt(), // CERT_E_WRONG_USAGE 0x800B0111.toInt(), // TRUST_E_EXPLICIT_DISTRUST ) +internal const val CERT_TRUST_REVOCATION_STATUS_UNKNOWN = 0x00000040 +internal const val CERT_TRUST_IS_OFFLINE_REVOCATION = 0x01000000 +private const val WINDOWS_UNKNOWN_REVOCATION_TRUST_BITS = + CERT_TRUST_REVOCATION_STATUS_UNKNOWN or CERT_TRUST_IS_OFFLINE_REVOCATION +internal val CRYPT_E_NO_REVOCATION_CHECK = 0x80092012.toInt() +internal val CRYPT_E_REVOCATION_OFFLINE = 0x80092013.toInt() +private val WINDOWS_UNKNOWN_REVOCATION_POLICY_ERRORS = setOf(CRYPT_E_NO_REVOCATION_CHECK, CRYPT_E_REVOCATION_OFFLINE) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt index 87941ed92..d3b723e9b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt @@ -5,6 +5,8 @@ import javax.net.ssl.SSLHandshakeException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -37,6 +39,11 @@ class WindowsExplicitDistrustVetoTest { for (verdict in listOf( WindowsChainVerdict.Rejected(CERT_TRUST_IS_UNTRUSTED_ROOT, CERT_E_UNTRUSTEDROOT), WindowsChainVerdict.Rejected(CERT_TRUST_IS_PARTIAL_CHAIN, CERT_E_CHAINING), + WindowsChainVerdict.Rejected(CERT_TRUST_REVOCATION_STATUS_UNKNOWN, CRYPT_E_NO_REVOCATION_CHECK), + WindowsChainVerdict.Rejected( + CERT_TRUST_REVOCATION_STATUS_UNKNOWN or CERT_TRUST_IS_OFFLINE_REVOCATION, + CRYPT_E_REVOCATION_OFFLINE, + ), WindowsChainVerdict.Failed(win32Error = 87), WindowsChainVerdict.Trusted, )) { @@ -49,6 +56,50 @@ class WindowsExplicitDistrustVetoTest { } } + @Test + fun unknownOrOfflineRevocationAloneNeverRejectsAChain() { + val unknown = CERT_TRUST_REVOCATION_STATUS_UNKNOWN or CERT_TRUST_IS_OFFLINE_REVOCATION + for ((status, policy) in listOf( + unknown to 0, + CERT_TRUST_REVOCATION_STATUS_UNKNOWN to CRYPT_E_NO_REVOCATION_CHECK, + unknown to CRYPT_E_REVOCATION_OFFLINE, + )) { + assertEquals(WindowsChainVerdict.Trusted, windowsChainVerdict(status, policy)) + } + } + + @Test + fun definiteRevocationIsRejectedAndVetoedEvenWithOtherwiseUnknownStatus() { + val verdict = windowsChainVerdict(CERT_TRUST_IS_REVOKED or CERT_TRUST_REVOCATION_STATUS_UNKNOWN, CERT_E_REVOKED) + val rejected = assertIs(verdict) + assertTrue(rejected.isExplicitWindowsDistrust()) + val trust = trustWithWindowsVerdict { verdict } + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + } + + @Test + fun otherWindowsErrorsAreRejectedWithoutBecomingAVeto() { + val unknown = CERT_TRUST_REVOCATION_STATUS_UNKNOWN or CERT_TRUST_IS_OFFLINE_REVOCATION + val untrusted = assertIs( + windowsChainVerdict(CERT_TRUST_IS_UNTRUSTED_ROOT or unknown, CERT_E_UNTRUSTEDROOT), + ) + assertFalse(untrusted.isExplicitWindowsDistrust()) + // An unknown-revocation policy code cannot hide a definite chain error. + assertIs(windowsChainVerdict(CERT_TRUST_IS_UNTRUSTED_ROOT, CRYPT_E_REVOCATION_OFFLINE)) + } + + @Test + fun unknownRevocationDoesNotNewlyFailTheWindowsTrustFallback() { + val verdict = windowsChainVerdict(CERT_TRUST_REVOCATION_STATUS_UNKNOWN or CERT_TRUST_IS_OFFLINE_REVOCATION, 0) + val trust = trustWithWindowsVerdict { verdict } + // The bundled roots reject this private CA; the Windows decision accepts it. + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + } + @Test fun vetoDoesNotTurnABundledRejectionIntoAcceptance() { val trust = trustWithWindowsVerdict { WindowsChainVerdict.Rejected(CERT_TRUST_IS_UNTRUSTED_ROOT, CERT_E_UNTRUSTEDROOT) }