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
165 changes: 133 additions & 32 deletions app/src/main/java/mysh/dev/gemcap/data/ClientCertRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import mysh.dev.gemcap.util.PemUtils
import org.json.JSONArray
import org.json.JSONObject
import java.security.KeyStore
import javax.naming.InvalidNameException
import javax.naming.ldap.LdapName

private const val TAG = "ClientCertRepository"

Expand All @@ -24,6 +22,8 @@ class ClientCertRepository(context: Context) {

private val prefs = context.getSharedPreferences("client_certs", Context.MODE_PRIVATE)
private val identityStorage = EncryptedIdentityStorage(context)
private val cacheLock = Any()
private var cachedCertificates: List<ClientCertificate>? = null

companion object {
private const val KEY_CERTIFICATES = "certificates"
Expand All @@ -39,7 +39,8 @@ class ClientCertRepository(context: Context) {
/**
* Retrieves all stored identities.
*/
fun getCertificates(): List<ClientCertificate> {
fun getCertificates(): List<ClientCertificate> = synchronized(cacheLock) {
cachedCertificates?.let { return it }
val json = prefs.getString(KEY_CERTIFICATES, null) ?: return emptyList()
return try {
val array = JSONArray(json)
Expand All @@ -50,22 +51,23 @@ class ClientCertRepository(context: Context) {
if (available.size != parsed.size) {
saveCertificates(available)
}
cachedCertificates = available
available
} catch (e: Exception) {
Log.e(TAG, "Failed to parse certificates JSON", e)
emptyList()
}
}

fun addCertificate(certificate: ClientCertificate) {
fun addCertificate(certificate: ClientCertificate): Unit = synchronized(cacheLock) {
val certificates = getCertificates().toMutableList()
certificates.removeAll { it.alias == certificate.alias }
certificates.add(0, certificate)
saveCertificates(certificates)
Log.d(TAG, "Added certificate: ${certificate.alias}")
}

fun addUsage(alias: String, usage: IdentityUsage) {
fun addUsage(alias: String, usage: IdentityUsage): Unit = synchronized(cacheLock) {
val certificates = getCertificates().map { cert ->
if (cert.alias == alias) {
val updatedUsages = cert.usages.filterNot {
Expand All @@ -80,7 +82,7 @@ class ClientCertRepository(context: Context) {
Log.d(TAG, "Added usage to $alias: $usage")
}

fun removeUsage(alias: String, usage: IdentityUsage) {
fun removeUsage(alias: String, usage: IdentityUsage): Unit = synchronized(cacheLock) {
val certificates = getCertificates().map { cert ->
if (cert.alias == alias) {
cert.copy(
Expand All @@ -96,7 +98,7 @@ class ClientCertRepository(context: Context) {
Log.d(TAG, "Removed usage from $alias: $usage")
}

fun removeCertificate(alias: String) {
fun removeCertificate(alias: String): Unit = synchronized(cacheLock) {
val deleteSucceeded = try {
identityStorage.deleteIdentity(alias)
} catch (e: Exception) {
Expand All @@ -113,7 +115,7 @@ class ClientCertRepository(context: Context) {
Log.d(TAG, "Removed certificate: $alias")
}

fun setActive(alias: String, isActive: Boolean) {
fun setActive(alias: String, isActive: Boolean): Unit = synchronized(cacheLock) {
val certificates = getCertificates().map {
if (it.alias == alias) it.copy(isActive = isActive) else it
}
Expand Down Expand Up @@ -251,25 +253,10 @@ class ClientCertRepository(context: Context) {
previous: ClientCertificate?
): ClientCertificate {
val subjectDn = certificate.subjectX500Principal.name
var commonName: String? = null
var email: String? = null
var organization: String? = null

try {
val ldapName = LdapName(subjectDn)
ldapName.rdns.forEach { rdn ->
val value = rdn.value?.toString() ?: return@forEach
when {
rdn.type.equals("CN", ignoreCase = true) && commonName == null -> commonName = value
rdn.type.equals("EMAILADDRESS", ignoreCase = true) && email == null -> email = value
rdn.type.equals("O", ignoreCase = true) && organization == null -> organization = value
}
}
} catch (_: IllegalArgumentException) {
// Fall back to the raw subject DN values below.
} catch (_: InvalidNameException) {
// Fall back to the raw subject DN values below.
}
val attrs = parseX500Dn(subjectDn)
val commonName = attrs["CN"]
val email = attrs["EMAILADDRESS"] ?: attrs["1.2.840.113549.1.9.1"]
val organization = attrs["O"]

return ClientCertificate(
alias = alias,
Expand All @@ -284,6 +271,119 @@ class ClientCertRepository(context: Context) {
)
}

/**
* Parses an RFC 2253 X.500 Distinguished Name into a map of attribute types to values.
* Returns the first value for each attribute type (uppercased keys).
* Supports both comma and semicolon separators per RFC 2253.
*/
private fun parseX500Dn(dn: String): Map<String, String> {
val result = mutableMapOf<String, String>()
for (rdn in splitDnComponents(dn)) {
for (subRdn in splitOnUnescapedPlus(rdn)) {
val eqIndex = subRdn.indexOf('=')
if (eqIndex > 0) {
val type = subRdn.substring(0, eqIndex).trim().uppercase()
val value = unescapeDnValue(subRdn.substring(eqIndex + 1).trim())
if (value.isNotEmpty()) {
result.putIfAbsent(type, value)
}
}
}
}
return result
}

/**
* Splits a single RDN on unescaped '+' to handle multivalued RDNs.
* Though I don't think it's possible to do something like this from
* Lagrange.
**/
private fun splitOnUnescapedPlus(rdn: String): List<String> {
val parts = mutableListOf<String>()
val current = StringBuilder()
var i = 0
var inQuote = false
while (i < rdn.length) {
val c = rdn[i]
when {
c == '\\' && i + 1 < rdn.length -> {
current.append(c).append(rdn[i + 1])
i += 2
}
c == '"' -> {
inQuote = !inQuote
i++
}
c == '+' && !inQuote -> {
parts.add(current.toString())
current.clear()
i++
}
else -> {
current.append(c)
i++
}
}
}
if (current.isNotEmpty()) {
parts.add(current.toString())
}
return parts
}

/**
* Splits an RFC 2253 DN string on unescaped commas or semicolons.
* */
private fun splitDnComponents(dn: String): List<String> {
val parts = mutableListOf<String>()
val current = StringBuilder()
var i = 0
var inQuote = false
while (i < dn.length) {
val c = dn[i]
when {
c == '\\' && i + 1 < dn.length -> {
current.append(c).append(dn[i + 1])
i += 2
}
c == '"' -> {
inQuote = !inQuote
i++
}
(c == ',' || c == ';') && !inQuote -> {
parts.add(current.toString())
current.clear()
i++
}
else -> {
current.append(c)
i++
}
}
}
if (current.isNotEmpty()) {
parts.add(current.toString())
}
return parts
}

/** Unescapes an RFC 2253 DN attribute value. */
private fun unescapeDnValue(value: String): String {
val trimmed = value.removeSurrounding("\"")
val sb = StringBuilder()
var i = 0
while (i < trimmed.length) {
if (trimmed[i] == '\\' && i + 1 < trimmed.length) {
sb.append(trimmed[i + 1])
i += 2
} else {
sb.append(trimmed[i])
i++
}
}
return sb.toString().trim()
}

private fun runBetaMigrationIfNeeded() {
if (prefs.getBoolean(KEY_BETA_MIGRATION_DONE, false)) {
return
Expand Down Expand Up @@ -341,7 +441,8 @@ class ClientCertRepository(context: Context) {
}
}

private fun saveCertificates(certificates: List<ClientCertificate>) {
private fun saveCertificates(certificates: List<ClientCertificate>) = synchronized(cacheLock) {
cachedCertificates = null
val array = JSONArray()
certificates.forEach { cert ->
val obj = JSONObject().apply {
Expand Down Expand Up @@ -373,8 +474,8 @@ class ClientCertRepository(context: Context) {
}
}

sealed class IdentityImportStoreResult {
data class Success(val certificate: ClientCertificate) : IdentityImportStoreResult()
data class Error(val message: String) : IdentityImportStoreResult()
object NeedsPassphrase : IdentityImportStoreResult()
sealed interface IdentityImportStoreResult {
data class Success(val certificate: ClientCertificate) : IdentityImportStoreResult
data class Error(val message: String) : IdentityImportStoreResult
data object NeedsPassphrase : IdentityImportStoreResult
}
115 changes: 115 additions & 0 deletions app/src/main/java/mysh/dev/gemcap/domain/CapsuleIdentityGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package mysh.dev.gemcap.domain

import android.annotation.SuppressLint
import android.net.Uri
import java.util.Locale

// Identity generation ported from Lagrange:
// - src/gmdocument.c:setThemeSeed_GmDocument()
// - src/gmutil.c:urlThemeSeed_String() / urlPaletteSeed_String()
object CapsuleIdentityGenerator {

// Lagrange: src/gmutil.c:282-287 (urlUser_String() regex patterns)
private val userPathPattern = Regex("^/~([^/?#]+)")
private val usersPathPattern = Regex("^/users/([^/?#]+)", RegexOption.IGNORE_CASE)

// Lagrange: src/gmdocument.c:1549-1556 (siteIcons)
// TODO: not all of them are being rendered correctly out of the box lmao,
// I guess I have to now figure out how fontpacks work?
private val siteIcons = intArrayOf(
0x203B, 0x2042, 0x205C, 0x2182, 0x25ED, 0x2600, 0x2601, 0x2604, 0x2605, 0x2606,
0x265C, 0x265E, 0x2690, 0x2691, 0x2693, 0x2698, 0x2699, 0x26F0, 0x270E, 0x2728,
0x272A, 0x272F, 0x2731, 0x2738, 0x273A, 0x273E, 0x2740, 0x2742, 0x2744, 0x2748,
0x274A, 0x2318, 0x2756, 0x2766, 0x27BD, 0x27C1, 0x27D0, 0x2B19, 0x1F300, 0x1F303,
0x1F306, 0x1F308, 0x1F30A, 0x1F319, 0x1F31F, 0x1F320, 0x1F340, 0x1F4CD, 0x1F4E1,
0x1F531, 0x1F533, 0x1F657, 0x1F659, 0x1F665, 0x1F668, 0x1F66B, 0x1F78B, 0x1F796,
0x1F79C
)

fun fromUrl(url: String): CapsuleIdentity {
val normalizedUrl = url.trim()
@SuppressLint("UseKtx")
val parsedUri = Uri.parse(normalizedUrl)
// Lagrange seed-source flow:
// setThemeSeed_GmDocument(..., urlPaletteSeed_String(url), urlThemeSeed_String(url))
// See src/gmdocument.c:2348 and src/gmutil.c:324-345.
val themeSeedSource = themeSeedSource(parsedUri)
val paletteSeedSource = paletteSeedSource(parsedUri, themeSeedSource)
val themeSeed = themeHash(themeSeedSource)
val paletteSeed = themeHash(paletteSeedSource)

var iconCodePoint = 0
if (paletteSeedSource.isNotEmpty()) {
// Lagrange: src/gmdocument.c:1561-1563
val index = (paletteSeed.toInt() ushr 7) % siteIcons.size
iconCodePoint = siteIcons[index]
}
// Lagrange special cases (no explanation though upon why):
// src/gmdocument.c:2124-2129
when (paletteSeedSource.lowercase(Locale.US)) {
"geminiprotocol.net" -> iconCodePoint = 0x264A
"spartan.mozz.us" -> iconCodePoint = 0x1F4AA
}

val icon = if (iconCodePoint != 0) String(Character.toChars(iconCodePoint)) else null

return CapsuleIdentity(
icon = icon,
themeSeed = themeSeed,
paletteSeed = paletteSeed,
themeSeedSource = themeSeedSource,
paletteSeedSource = paletteSeedSource
)
}

private fun themeSeedSource(uri: Uri): String {
if (uri.scheme?.equals("file", ignoreCase = true) == true) return ""
val path = uri.encodedPath.orEmpty()
val userMatch = userPathPattern.find(path)?.groupValues?.getOrNull(1)
?: usersPathPattern.find(path)?.groupValues?.getOrNull(1)
return userMatch?.takeIf { it.isNotBlank() } ?: uri.host.orEmpty().lowercase(Locale.US)
}


// Partial port of src/gmutil.c:urlPaletteSeed_String() (335-345).
private fun paletteSeedSource(uri: Uri, defaultSource: String): String {
// Lagrange also checks a site-specific override (valueString_SiteSpec + paletteSeed key),
// which is not implemented in Gemcap.
//
// The file-scheme check is redundant with themeSeedSource but kept for parity with
// Lagrange's urlPaletteSeed_String() and to support future site-specific overrides
// that might never arrive.
if (uri.scheme.equals("file", ignoreCase = true)) {
return ""
}
return defaultSource
}

// Lagrange: src/gmdocument.c:88-152 (themeHash_ CRC-32 table)
@OptIn(ExperimentalUnsignedTypes::class)
private val crc32Table: UIntArray by lazy {
UIntArray(256) { i ->
var crc = i.toUInt()
repeat(8) {
crc = if ((crc and 1u) != 0u) {
0xEDB88320u xor (crc shr 1)
} else {
crc shr 1
}
}
crc
}
}

// Lagrange: src/gmdocument.c:88 (themeHash_)
@OptIn(ExperimentalUnsignedTypes::class)
private fun themeHash(seedSource: String): UInt {
if (seedSource.isEmpty()) return 0u
var crc = 0u
for (b in seedSource.toByteArray(Charsets.UTF_8)) {
val idx = ((crc xor (b.toInt() and 0xFF).toUInt()) and 0xFFu).toInt()
crc = crc32Table[idx] xor (crc shr 8)
}
return crc
}
}
Loading