Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions PLATFORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/471-desktop-system-trust-store.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
53 changes: 53 additions & 0 deletions docs/login-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<X509Certificate>) : 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<Path> = 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<Path> = 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<String, X509Certificate>()
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("-----")
Comment thread
veryCrunchy marked this conversation as resolved.
}
}
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
Loading
Loading