diff --git a/PLATFORMS.md b/PLATFORMS.md index 2aa943643..9159f1dd7 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -342,6 +342,58 @@ 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 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. + 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, 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, 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. 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). + ## 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 c3fad7475..589fc3e79 100644 --- a/docs/login-diagnostics.md +++ b/docs/login-diagnostics.md @@ -40,6 +40,59 @@ Because no connection is kept between checks, returning to the app does not reuse one that ended while the app was in the background. A new sign-in attempt also never shares a connection with a check from an earlier 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, 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`, + `/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** 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. Windows can also veto a certificate + that the bundled Java roots accept: a certificate in the Disallowed store, + 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. + +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. +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..dbd75c0b0 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") } } } 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 ffab0d414..e8f167da4 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..b78e13a10 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt @@ -0,0 +1,169 @@ +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.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 + +/** Bounded, non-secret reason why no operating-system anchors were added. */ +internal enum class DesktopSystemTrustUnavailableReason { + UnsupportedPlatform, + NotFound, + Unreadable, + Malformed, + TooLarge, + Empty, + StoreUnavailable, +} + +/** Trust anchors read from a Linux system CA bundle. */ +internal sealed interface DesktopSystemTrustAnchors { + data class Loaded(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) } + +/** + * 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() + // 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("-----") + } + } + if (block != null || skippedLabel != null) return unavailable(DesktopSystemTrustUnavailableReason.Malformed) + if (certificates.isEmpty()) return unavailable(DesktopSystemTrustUnavailableReason.Empty) + return DesktopSystemTrustAnchors.Loaded(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) } +} + +private fun unavailable(reason: DesktopSystemTrustUnavailableReason) = + DesktopSystemTrustAnchors.Unavailable(reason) + +private const val PEM_CERTIFICATE_BEGIN = "-----BEGIN CERTIFICATE-----" +private const val PEM_CERTIFICATE_END = "-----END CERTIFICATE-----" +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..3a73481e2 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrust.kt @@ -0,0 +1,228 @@ +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 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 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. [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 + return sslSocketFactory(trust.sslSocketFactory, trust.trustManager) +} + +private val sharedDesktopTlsTrust: DesktopTlsTrust by lazy { + 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( + systemTrust: DesktopSystemTrust, + bundled: X509TrustManager = bundledJvmTrustManager(), +): DesktopTlsTrust { + val extendedBundled = bundled as? X509ExtendedTrustManager + 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, systemTrust.veto) to + DesktopSystemTrustStatus.Active(systemTrust.source) + } + } + val context = SSLContext.getInstance("TLS").apply { init(null, arrayOf(effective), null) } + return DesktopTlsTrust( + trustManager = effective, + sslSocketFactory = context.socketFactory, + systemTrust = status, + ) +} + +/** 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 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. 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()) + .distinctBy { Base64.getEncoder().encodeToString(it.encoded) } + .toList() + .toTypedArray() + + 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) }, + ) + + // 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( + chain: Array?, + bundledCheck: () -> Unit, + systemCheck: () -> Unit, + ) { + val bundledFailure = try { + bundledCheck() + 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/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/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..c211dd55f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrust.kt @@ -0,0 +1,341 @@ +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.") +} + +/** + * 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 { + JnaWindowsServerChainVerifier() + } catch (_: UnsatisfiedLinkError) { + return DesktopSystemTrust.Unavailable(DesktopSystemTrustUnavailableReason.StoreUnavailable) + } + return DesktopSystemTrust.Available( + DesktopSystemTrustSource.WindowsChainEngine, + WindowsChainEngineTrustManager(verifier), + WindowsExplicitDistrustVeto(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 + // Revocation is cache-only; this bounds any retrieval Windows still attempts. + dwUrlRetrievalTimeout = WINDOWS_REVOCATION_TIMEOUT_MILLIS + } + 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() + // 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() } + 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. + 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) + } +} + +@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 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 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 +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 +) +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/DesktopSystemTrustAnchorsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt new file mode 100644 index 000000000..147099f09 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchorsTest.kt @@ -0,0 +1,138 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +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( + 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", + "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( + 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)), + ) + } + + /** 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(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 new file mode 100644 index 000000000..a7e2fd9b1 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopTlsTrustTest.kt @@ -0,0 +1,178 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.security.cert.CertificateException +import java.util.concurrent.CopyOnWriteArrayList +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException +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 okhttp3.OkHttpClient +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(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + assertIs(trust.trustManager) + assertEquals(DesktopSystemTrustStatus.Active(DesktopSystemTrustSource.LinuxCaBundle), trust.systemTrust) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + } + + @Test + fun bundledRootsRemainTrustedAlongsideOperatingSystemAnchors() { + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + } + + @Test + fun serverIssuedByAnUntrustedCaIsRejected() { + 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(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("nextcloud.invalid", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + } + + @Test + fun expiredLeafFromATrustedCaIsRejected() { + val trust = desktopTlsTrust(linuxAnchors(privateAuthority), bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticExpiredLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(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 = pkixTrustManagerFor(bundledAuthority) + for (anchors in listOf( + loadLinuxSystemCaBundle(listOf(malformed)), + loadLinuxSystemCaBundle(listOf(directory.resolve("missing.crt"))), + )) { + val trust = desktopTlsTrust(linuxBundleTrust(anchors), bundled = bundled) + assertIs(trust.systemTrust) + assertSame(bundled, trust.trustManager) + withSyntheticTlsServer(syntheticLeaf("localhost", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + withSyntheticTlsServer(syntheticLeaf("localhost", bundledAuthority)) { url -> + assertEquals("trusted", fetchWithTrust(trust, url)) + } + } + } + + @Test + fun acceptedIssuersIncludeBothAnchorSetsForChainCleaning() { + 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 + assertEquals(issuers, trust.trustManager.acceptedIssuers.toSet()) + } + + @Test + 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()) + } + + @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) + } + } + } + + @Test + fun windowsTrustStillLeavesHostnameVerificationToOkHttp() { + val trust = desktopTlsTrust(windowsTrust { WindowsChainVerdict.Trusted }, bundled = pkixTrustManagerFor(bundledAuthority)) + withSyntheticTlsServer(syntheticLeaf("nextcloud.invalid", privateAuthority)) { url -> + assertFailsWith { fetchWithTrust(trust, url) } + } + } + + @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) + } + + @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) + } + + @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..89f9183c3 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsChainEngineTrustTest.kt @@ -0,0 +1,208 @@ +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) } + } + } + + @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, + 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 + +/** 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, +) 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..d3b723e9b --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsExplicitDistrustVetoTest.kt @@ -0,0 +1,137 @@ +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.assertFalse +import kotlin.test.assertIs +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.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, + )) { + 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 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) } + 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() diff --git a/website/content/guides/desktop-getting-started.md b/website/content/guides/desktop-getting-started.md index c0f8cdb01..e1346e1d2 100644 --- a/website/content/guides/desktop-getting-started.md +++ b/website/content/guides/desktop-getting-started.md @@ -33,6 +33,14 @@ 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. +### 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 @capture-alt: nati.ve desktop Apps workspace with pinned tools, recent work, categories, search, installed app cards, and the persistent sidebar diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index e6b9c767e..1fdc84404 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -510,7 +510,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": "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",