Add FingerprintFormatter

This commit is contained in:
cketti 2024-01-16 16:51:20 +01:00
parent 5ebf7e50af
commit 75a9287463
11 changed files with 267 additions and 47 deletions

View file

@ -20,6 +20,7 @@ data class Colors(
val onPrimary: Color,
val onSecondary: Color,
val onBackground: Color,
val onBackgroundSecondary: Color,
val onSurface: Color,
val onMessage: Color,
val toolbar: Color,
@ -41,6 +42,7 @@ internal fun lightColors(
onPrimary: Color = Color.White,
onSecondary: Color = Color.Black,
onBackground: Color = Color.Black,
onBackgroundSecondary: Color = MaterialColor.gray_700,
onSurface: Color = Color.Black,
onMessage: Color = Color.White,
toolbar: Color = primary,
@ -58,6 +60,7 @@ internal fun lightColors(
onPrimary = onPrimary,
onSecondary = onSecondary,
onBackground = onBackground,
onBackgroundSecondary = onBackgroundSecondary,
onSurface = onSurface,
onMessage = onMessage,
toolbar = toolbar,
@ -79,6 +82,7 @@ internal fun darkColors(
onPrimary: Color = Color.Black,
onSecondary: Color = Color.Black,
onBackground: Color = Color.White,
onBackgroundSecondary: Color = MaterialColor.gray_400,
onSurface: Color = Color.White,
onMessage: Color = Color.Black,
toolbar: Color = surface,
@ -96,6 +100,7 @@ internal fun darkColors(
onPrimary = onPrimary,
onSecondary = onSecondary,
onBackground = onBackground,
onBackgroundSecondary = onBackgroundSecondary,
onSurface = onSurface,
onMessage = onMessage,
toolbar = toolbar,

View file

@ -13,6 +13,7 @@ dependencies {
implementation(projects.feature.account.common)
implementation(projects.mail.common)
implementation(libs.okio)
testImplementation(projects.core.ui.compose.testing)
}

View file

@ -4,7 +4,9 @@ import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertific
import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract
import app.k9mail.feature.account.server.certificate.domain.usecase.AddServerCertificateException
import app.k9mail.feature.account.server.certificate.domain.usecase.FormatServerCertificateError
import app.k9mail.feature.account.server.certificate.ui.DefaultFingerprintFormatter
import app.k9mail.feature.account.server.certificate.ui.DefaultServerNameFormatter
import app.k9mail.feature.account.server.certificate.ui.FingerprintFormatter
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorViewModel
import app.k9mail.feature.account.server.certificate.ui.ServerNameFormatter
import org.koin.androidx.viewmodel.dsl.viewModel
@ -29,6 +31,8 @@ val featureAccountServerCertificateModule: Module = module {
factory<ServerNameFormatter> { DefaultServerNameFormatter() }
factory<FingerprintFormatter> { DefaultFingerprintFormatter() }
viewModel {
ServerCertificateErrorViewModel(
certificateErrorRepository = get(),

View file

@ -1,12 +1,14 @@
package app.k9mail.feature.account.server.certificate.domain.entity
import okio.ByteString
data class ServerCertificateProperties(
val subjectAlternativeNames: List<String>,
val notValidBefore: String,
val notValidAfter: String,
val subject: String,
val issuer: String,
val fingerprintSha1: String,
val fingerprintSha256: String,
val fingerprintSha512: String,
val fingerprintSha1: ByteString,
val fingerprintSha256: ByteString,
val fingerprintSha512: ByteString,
)

View file

@ -4,15 +4,14 @@ import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDom
import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
import com.fsck.k9.logging.Timber
import com.fsck.k9.mail.filter.Hex
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.X509Certificate
import java.text.DateFormat
import java.util.Date
import kotlinx.datetime.Instant
import okio.ByteString
import okio.HashingSink
import okio.blackholeSink
import okio.buffer
class FormatServerCertificateError(
private val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT),
@ -33,9 +32,9 @@ class FormatServerCertificateError(
val subject = certificate.subjectX500Principal.toString()
val issuer = certificate.issuerX500Principal.toString()
val fingerprintSha1 = getFingerprint(certificate, algorithm = "SHA-1")
val fingerprintSha256 = getFingerprint(certificate, algorithm = "SHA-256")
val fingerprintSha512 = getFingerprint(certificate, algorithm = "SHA-512")
val fingerprintSha1 = computeFingerprint(certificate, HashAlgorithm.SHA_1)
val fingerprintSha256 = computeFingerprint(certificate, HashAlgorithm.SHA_256)
val fingerprintSha512 = computeFingerprint(certificate, HashAlgorithm.SHA_512)
return FormattedServerCertificateError(
hostname = serverCertificateError.hostname,
@ -52,28 +51,23 @@ class FormatServerCertificateError(
)
}
private fun getFingerprint(certificate: X509Certificate, algorithm: String): String {
val fingerprint = computeFingerprint(certificate, algorithm)
return formatFingerprint(fingerprint)
}
private fun computeFingerprint(certificate: X509Certificate, algorithm: String): ByteArray {
val digest = try {
MessageDigest.getInstance(algorithm)
} catch (e: NoSuchAlgorithmException) {
Timber.e(e, "Error while initializing MessageDigest (%s)", algorithm)
return "??".toByteArray()
private fun computeFingerprint(certificate: X509Certificate, algorithm: HashAlgorithm): ByteString {
val sink = when (algorithm) {
HashAlgorithm.SHA_1 -> HashingSink.sha1(blackholeSink())
HashAlgorithm.SHA_256 -> HashingSink.sha256(blackholeSink())
HashAlgorithm.SHA_512 -> HashingSink.sha512(blackholeSink())
}
return try {
digest.digest(certificate.encoded)
} catch (e: CertificateEncodingException) {
Timber.e(e, "Error while encoding certificate")
"??".toByteArray()
}
}
sink.buffer()
.write(certificate.encoded)
.flush()
private fun formatFingerprint(fingerprint: ByteArray): String {
return Hex.encodeHex(fingerprint)
return sink.hash
}
}
private enum class HashAlgorithm {
SHA_1,
SHA_256,
SHA_512,
}

View file

@ -0,0 +1,50 @@
package app.k9mail.feature.account.server.certificate.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.withStyle
import okio.ByteString
/**
* Format a certificate fingerprint.
*
* Outputs bytes as hexadecimal number, separated by `:`. Includes zero width space (U+200B) after colons to decrease
* the chance of long lines being displayed with a line break in the middle of a byte.
*/
internal fun interface FingerprintFormatter {
fun format(fingerprint: ByteString, separatorColor: Color): AnnotatedString
}
internal class DefaultFingerprintFormatter : FingerprintFormatter {
override fun format(fingerprint: ByteString, separatorColor: Color): AnnotatedString {
require(fingerprint.size > 0)
return buildAnnotatedString {
appendByteAsHexNumber(fingerprint[0])
for (i in 1 until fingerprint.size) {
appendSeparator(separatorColor)
appendByteAsHexNumber(fingerprint[i])
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun AnnotatedString.Builder.appendByteAsHexNumber(byte: Byte) {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
append(byte.toHexString(format = HexFormat.UpperCase))
}
}
private fun AnnotatedString.Builder.appendSeparator(separatorColor: Color) {
withStyle(style = SpanStyle(color = separatorColor)) {
append(":")
}
// Zero width space so long lines will be broken here and not in the middle of a byte value
append('\u200B')
}
}

View file

@ -32,6 +32,7 @@ import app.k9mail.feature.account.server.certificate.R
import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State
import okio.ByteString.Companion.decodeHex
import org.koin.compose.koinInject
@Composable
@ -126,16 +127,19 @@ internal fun ServerCertificateErrorContentPreview() {
notValidAfter = "December 31, 2023, 11:59 PM",
subject = "CN=*.domain.example",
issuer = "CN=test, O=MZLA",
fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d",
fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f",
fingerprintSha512 = "81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa67988b2ada669ab" +
"4837fcd3d4ea6e2b8db2b9da9197d5112fb369fd006da545de",
fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d".decodeHex(),
fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f".decodeHex(),
fingerprintSha512 = (
"81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa6" +
"7988b2ada669ab4837fcd3d4ea6e2b8db2b9da9197d5112fb369fd006da545de"
).decodeHex(),
),
),
)
koinPreview {
factory<ServerNameFormatter> { DefaultServerNameFormatter() }
factory<FingerprintFormatter> { DefaultFingerprintFormatter() }
} WithContent {
K9Theme {
ServerCertificateErrorContent(

View file

@ -171,6 +171,7 @@ internal fun ServerCertificateErrorScreenK9Preview() {
koinPreview {
factory<ServerNameFormatter> { DefaultServerNameFormatter() }
factory<FingerprintFormatter> { DefaultFingerprintFormatter() }
} WithContent {
K9Theme {
ServerCertificateErrorScreen(

View file

@ -18,6 +18,8 @@ import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.feature.account.server.certificate.R
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import org.koin.compose.koinInject
@Composable
@ -25,6 +27,7 @@ internal fun ServerCertificateView(
serverCertificateProperties: ServerCertificateProperties,
modifier: Modifier = Modifier,
serverNameFormatter: ServerNameFormatter = koinInject(),
fingerprintFormatter: FingerprintFormatter = koinInject(),
) {
Column(
modifier = modifier.padding(
@ -66,16 +69,26 @@ internal fun ServerCertificateView(
TextOverline(text = stringResource(R.string.account_server_certificate_fingerprints_section))
Spacer(modifier = Modifier.height(MainTheme.spacings.default))
TextSubtitle2(text = "SHA-1")
TextBody1(text = serverCertificateProperties.fingerprintSha1)
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
Fingerprint("SHA-1", serverCertificateProperties.fingerprintSha1, fingerprintFormatter)
Fingerprint("SHA-256", serverCertificateProperties.fingerprintSha256, fingerprintFormatter)
Fingerprint("SHA-512", serverCertificateProperties.fingerprintSha512, fingerprintFormatter)
}
}
TextSubtitle2(text = "SHA-256")
TextBody1(text = serverCertificateProperties.fingerprintSha256)
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
@Composable
private fun Fingerprint(
title: String,
fingerprint: ByteString,
fingerprintFormatter: FingerprintFormatter,
) {
val formattedFingerprint = fingerprintFormatter.format(
fingerprint,
separatorColor = MainTheme.colors.onBackgroundSecondary,
)
TextSubtitle2(text = "SHA-512")
TextBody1(text = serverCertificateProperties.fingerprintSha512)
Column {
TextSubtitle2(text = title)
TextBody1(text = formattedFingerprint)
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
}
}
@ -104,14 +117,17 @@ internal fun ServerCertificateViewPreview() {
notValidAfter = "December 31, 2023, 11:59 PM",
subject = "CN=*.domain.example",
issuer = "CN=test, O=MZLA",
fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d",
fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f",
fingerprintSha512 = "81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa67988b2ada669ab4837fcd3d4" +
"ea6e2b8db2b9da9197d5112fb369fd006da545de",
fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d".decodeHex(),
fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f".decodeHex(),
fingerprintSha512 = (
"81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa6" +
"7988b2ada669ab4837fcd3d4ea6e2b8db2b9da9197d5112fb369fd006da545de"
).decodeHex(),
)
koinPreview {
factory<ServerNameFormatter> { DefaultServerNameFormatter() }
factory<FingerprintFormatter> { DefaultFingerprintFormatter() }
} WithContent {
K9Theme {
ServerCertificateView(

View file

@ -0,0 +1,96 @@
package app.k9mail.feature.account.server.certificate.domain.usecase
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
import assertk.assertThat
import assertk.assertions.isEqualTo
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.DateFormat
import java.util.Locale
import java.util.TimeZone
import kotlin.test.BeforeTest
import kotlin.test.Test
import okio.ByteString.Companion.decodeHex
class FormatServerCertificateErrorTest {
@BeforeTest
fun setTimeZone() {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@Test
fun `format expired certificate`() {
val formatCertificateError = FormatServerCertificateError(
dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, Locale.ROOT),
)
val serverCertificateError = ServerCertificateError(
hostname = "expired.badssl.com",
port = 443,
certificateChain = listOf(readCertificate(EXPIRED_CERTIFICATE)),
)
val result = formatCertificateError(serverCertificateError)
assertThat(result.hostname).isEqualTo("expired.badssl.com")
assertThat(result.serverCertificateProperties).isEqualTo(
ServerCertificateProperties(
subjectAlternativeNames = listOf("*.badssl.com", "badssl.com"),
notValidBefore = "2015 Apr 9 00:00",
notValidAfter = "2015 Apr 12 23:59",
subject = "CN=*.badssl.com, OU=PositiveSSL Wildcard, OU=Domain Control Validated",
issuer = "CN=COMODO RSA Domain Validation Secure Server CA, O=COMODO CA Limited, L=Salford, " +
"ST=Greater Manchester, C=GB",
fingerprintSha1 = "404bbd2f1f4cc2fdeef13aabdd523ef61f1c71f3".decodeHex(),
fingerprintSha256 = "ba105ce02bac76888ecee47cd4eb7941653e9ac993b61b2eb3dcc82014d21b4f".decodeHex(),
fingerprintSha512 = (
"851d7249d64f85d1242090b06224b6da67d442ae38cea5d8a78ae1d7d8c3e2f8" +
"f4ad44c7cf239ba5abb05170e0910fd72e6ea5e5c2604888f6c59e5f57c3db27"
).decodeHex(),
),
)
}
private fun readCertificate(asciiArmoredCertificate: String): X509Certificate {
val inputStream = asciiArmoredCertificate.byteInputStream()
val certificateFactory = CertificateFactory.getInstance("X.509")
return certificateFactory.generateCertificate(inputStream) as X509Certificate
}
companion object {
val EXPIRED_CERTIFICATE = """
-----BEGIN CERTIFICATE-----
MIIFSzCCBDOgAwIBAgIQSueVSfqavj8QDxekeOFpCTANBgkqhkiG9w0BAQsFADCB
kDELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxNjA0BgNV
BAMTLUNPTU9ETyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD
QTAeFw0xNTA0MDkwMDAwMDBaFw0xNTA0MTIyMzU5NTlaMFkxITAfBgNVBAsTGERv
bWFpbiBDb250cm9sIFZhbGlkYXRlZDEdMBsGA1UECxMUUG9zaXRpdmVTU0wgV2ls
ZGNhcmQxFTATBgNVBAMUDCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAMIE7PiM7gTCs9hQ1XBYzJMY61yoaEmwIrX5lZ6xKyx2PmzA
S2BMTOqytMAPgLaw+XLJhgL5XEFdEyt/ccRLvOmULlA3pmccYYz2QULFRtMWhyef
dOsKnRFSJiFzbIRMeVXk0WvoBj1IFVKtsyjbqv9u/2CVSndrOfEk0TG23U3AxPxT
uW1CrbV8/q71FdIzSOciccfCFHpsKOo3St/qbLVytH5aohbcabFXRNsKEqveww9H
dFxBIuGa+RuT5q0iBikusbpJHAwnnqP7i/dAcgCskgjZjFeEU4EFy+b+a1SYQCeF
xxC7c3DvaRhBB0VVfPlkPz0sw6l865MaTIbRyoUCAwEAAaOCAdUwggHRMB8GA1Ud
IwQYMBaAFJCvajqUWgvYkOoSVnPfQ7Q6KNrnMB0GA1UdDgQWBBSd7sF7gQs6R2lx
GH0RN5O8pRs/+zAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNVHSUE
FjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwTwYDVR0gBEgwRjA6BgsrBgEEAbIxAQIC
BzArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21vZG8uY29tL0NQUzAI
BgZngQwBAgEwVAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5jb21vZG9jYS5j
b20vQ09NT0RPUlNBRG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZlckNBLmNybDCB
hQYIKwYBBQUHAQEEeTB3ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LmNvbW9kb2Nh
LmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0EuY3J0
MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wIwYDVR0RBBww
GoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBq
evHa/wMHcnjFZqFPRkMOXxQhjHUa6zbgH6QQFezaMyV8O7UKxwE4PSf9WNnM6i1p
OXy+l+8L1gtY54x/v7NMHfO3kICmNnwUW+wHLQI+G1tjWxWrAPofOxkt3+IjEBEH
fnJ/4r+3ABuYLyw/zoWaJ4wQIghBK4o+gk783SHGVnRwpDTysUCeK1iiWQ8dSO/r
ET7BSp68ZVVtxqPv1dSWzfGuJ/ekVxQ8lEEFeouhN0fX9X3c+s5vMaKwjOrMEpsi
8TRwz311SotoKQwe6Zaoz7ASH1wq7mcvf71z81oBIgxw+s1F73hczg36TuHvzmWf
RwxPuzZEaFZcVlmtqoq8
-----END CERTIFICATE-----
""".trimIndent()
}
}

View file

@ -0,0 +1,47 @@
package app.k9mail.feature.account.server.certificate.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.withStyle
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import okio.ByteString.Companion.decodeHex
class FingerprintFormatterTest {
private val formatter = DefaultFingerprintFormatter()
@Test
fun `simple fingerprint`() {
val fingerprint = "0088FF".decodeHex()
val separatorColor = Color.Cyan
val result = formatter.format(fingerprint, separatorColor)
assertThat(result).isEqualTo(
buildAnnotatedString {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
append("00")
}
withStyle(SpanStyle(color = separatorColor)) {
append(":")
}
append('\u200B')
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
append("88")
}
withStyle(SpanStyle(color = separatorColor)) {
append(":")
}
append('\u200B')
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
append("FF")
}
},
)
}
}