Repository navigation
feat(desktop): honor operating-system trusted certificate authorities #501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
af2248e
feat(desktop): honor operating-system trusted certificate authorities
veryCrunchy a45ebe0
chore(website): refresh marketing captures
obiente-automations[bot] 1af546f
fix(desktop): let the Windows chain engine decide OS certificate trust
veryCrunchy 95e9fbf
fix(desktop): reject orphan certificate terminators in system CA bundles
veryCrunchy 1cadcaa
docs(website): date the private certificate authority guidance separa…
veryCrunchy aeee5da
chore(website): refresh marketing captures
obiente-automations[bot] 03c60a7
Merge remote-tracking branch 'origin/main' into fix/desktop-system-tr…
veryCrunchy bd4c2ac
Merge remote-tracking branch 'origin/main' into fix/desktop-system-tr…
veryCrunchy 7eb2fdf
fix(desktop): let explicit Windows distrust veto bundled acceptance
veryCrunchy ff14b37
fix(desktop): check cached revocation in the Windows chain engine
veryCrunchy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
169 changes: 169 additions & 0 deletions
169
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemTrustAnchors.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("-----") | ||
| } | ||
| } | ||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.