Fix SSD review local truth sync and rotate status
ober
3ffae5450a6ad1e34af1932f9e14c9d5107823f0
--- a/templates/ssd-review.ss +++ b/templates/ssd-review.ss @@ -719,8 +719,11 @@ " }" "" " private fun rotatePage() {" + " loadGeneration += 1" " reviewView.rotateClockwise()" - " setStatus(\"Rotated page\")" + " val session = currentSession" + " val suffix = if (session != null) \"; ${session.cells.size} cells, ${session.groups.size} groups\" else \"\"" + " setStatus(\"Rotated page$suffix\")" " }" "" " private fun showPageDialog() {" @@ -3182,6 +3185,7 @@ "import java.io.FilterOutputStream" "import java.io.InputStream" "import java.io.OutputStream" + "import java.net.HttpURLConnection" "import java.net.URL" "import java.net.URLEncoder" "import java.nio.channels.FileChannel" @@ -3211,6 +3215,7 @@ "" "class TruthStore(private val context: Context) {" " companion object {" + " private const val DEFAULT_TRUTH_API_URL = \"http://10.66.60.2:8797/api\"" " private const val MAX_HTTP_TEXT_BYTES = 32L * 1024L * 1024L" " private const val MAX_HTTP_COMPRESSED_BYTES = 8L * 1024L * 1024L" " private const val MAX_REMOTE_ZIP_BYTES = 96L * 1024L * 1024L" @@ -3231,7 +3236,7 @@ "" " private data class RemoteConfig(" " val apiUrl: URL," - " val spkiSha256: ByteArray," + " val spkiSha256: ByteArray?," " val bearerToken: String" " )" "" @@ -3488,32 +3493,56 @@ "" " private fun loadRemoteConfig(): RemoteConfig? {" " val preferences = context.getSharedPreferences(\"ssd_review_remote\", Context.MODE_PRIVATE)" - " val endpoint = preferences.getString(\"base_url\", null)?.trim().orEmpty()" + " val savedEndpoint = preferences.getString(\"base_url\", null)?.trim().orEmpty()" + " val endpoint = normalizeRemoteEndpoint(savedEndpoint.ifBlank { DEFAULT_TRUTH_API_URL })" " val pinText = preferences.getString(\"spki_sha256\", null)?.trim().orEmpty()" " val token = preferences.getString(\"bearer_token\", null)?.trim().orEmpty()" - " if (endpoint.isBlank() && pinText.isBlank() && token.isBlank()) return null" - " require(endpoint.isNotBlank() && pinText.isNotBlank() && token.isNotBlank()) {" - " \"Remote SSD service configuration is incomplete\"" - " }" " val url = URL(endpoint.trimEnd('/'))" - " require(url.protocol == \"https\" && url.host.isNotBlank()) { \"HTTPS endpoint required\" }" + " require((url.protocol == \"http\" || url.protocol == \"https\") && url.host.isNotBlank()) { \"HTTP(S) endpoint required\" }" + " if (url.protocol == \"http\") {" + " require(localTruthHttpAllowed(url.host)) { \"HTTP truth sync is only allowed for local/private hosts\" }" + " }" " require(url.userInfo == null && url.query == null && url.ref == null) { \"Unsafe endpoint URL\" }" - " require(token.length in 32..MAX_AUTH_TOKEN_CHARS && !token.contains('\\n') && !token.contains('\\r')) {" - " \"Invalid remote authentication token\"" + " if (token.isNotBlank()) {" + " require(token.length <= MAX_AUTH_TOKEN_CHARS && !token.contains('\\n') && !token.contains('\\r')) {" + " \"Invalid remote authentication token\"" + " }" + " }" + " val pin = if (pinText.isNotBlank()) {" + " val encodedPin = pinText.removePrefix(\"sha256/\")" + " Base64.decode(encodedPin, Base64.NO_WRAP).also {" + " require(it.size == 32) { \"SPKI SHA-256 pin must decode to 32 bytes\" }" + " }" + " } else {" + " null" " }" - " val encodedPin = pinText.removePrefix(\"sha256/\")" - " val pin = Base64.decode(encodedPin, Base64.NO_WRAP)" - " require(pin.size == 32) { \"SPKI SHA-256 pin must decode to 32 bytes\" }" + " require(url.protocol == \"https\" || pin == null) { \"SPKI pin requires HTTPS\" }" " return RemoteConfig(url, pin, token)" " }" "" + " private fun normalizeRemoteEndpoint(value: String): String {" + " var endpoint = value.trim().ifBlank { DEFAULT_TRUTH_API_URL }" + " if (!endpoint.startsWith(\"http://\") && !endpoint.startsWith(\"https://\")) {" + " endpoint = \"http://$endpoint\"" + " }" + " endpoint = endpoint.replace(\"10.66.69.2\", \"10.66.60.2\")" + " return if (endpoint.trimEnd('/').endsWith(\"/api\")) endpoint else endpoint.trimEnd('/') + \"/api\"" + " }" + "" + " private fun localTruthHttpAllowed(host: String): Boolean {" + " val lower = host.lowercase()" + " if (lower == \"localhost\" || lower == \"127.0.0.1\" || lower == \"10.66.60.2\") return true" + " if (lower.startsWith(\"10.\") || lower.startsWith(\"192.168.\")) return true" + " return Regex(\"^172\\\\.(1[6-9]|2[0-9]|3[0-1])\\\\.\").containsMatchIn(lower)" + " }" + "" " private fun remoteUrl(relative: String): URL {" " require(relative.isNotBlank() && !relative.startsWith('/') && !relative.contains(\"..\"))" " val config = remoteConfig ?: throw IllegalStateException(\"Remote SSD service is not locally configured\")" " val candidate = URL(config.apiUrl.toString().trimEnd('/') + \"/\" + relative)" " val basePort = if (config.apiUrl.port >= 0) config.apiUrl.port else config.apiUrl.defaultPort" " val candidatePort = if (candidate.port >= 0) candidate.port else candidate.defaultPort" - " require(candidate.protocol == \"https\" && candidate.host == config.apiUrl.host && candidatePort == basePort) {" + " require(candidate.protocol == config.apiUrl.protocol && candidate.host == config.apiUrl.host && candidatePort == basePort) {" " \"Remote URL escaped configured HTTPS origin\"" " }" " return candidate" @@ -3525,15 +3554,17 @@ " outputBytes: Long? = null," " contentType: String? = null," " acceptGzip: Boolean = false" - " ): HttpsURLConnection {" + " ): HttpURLConnection {" " val config = remoteConfig ?: throw IllegalStateException(\"Remote SSD service is not locally configured\")" - " val connection = (remoteUrl(relative).openConnection() as HttpsURLConnection).apply {" - " sslSocketFactory = pinnedSocketFactory(config)" + " val connection = (remoteUrl(relative).openConnection() as HttpURLConnection).apply {" + " if (this is HttpsURLConnection && config.spkiSha256 != null) {" + " sslSocketFactory = pinnedSocketFactory(config)" + " }" " requestMethod = method" " connectTimeout = 5000" " readTimeout = 30000" " instanceFollowRedirects = false" - " setRequestProperty(\"Authorization\", \"Bearer ${config.bearerToken}\")" + " if (config.bearerToken.isNotBlank()) setRequestProperty(\"Authorization\", \"Bearer ${config.bearerToken}\")" " setRequestProperty(\"Accept\", \"application/json\")" " if (acceptGzip) setRequestProperty(\"Accept-Encoding\", \"gzip\")" " if (contentType != null) setRequestProperty(\"Content-Type\", contentType)" @@ -3544,17 +3575,20 @@ " }" " }" " connection.connect()" - " val certificate = connection.serverCertificates.firstOrNull()" - " ?: throw SSLPeerUnverifiedException(\"Server provided no certificate\")" - " val actualPin = MessageDigest.getInstance(\"SHA-256\").digest(certificate.publicKey.encoded)" - " if (!MessageDigest.isEqual(actualPin, config.spkiSha256)) {" - " connection.disconnect()" - " throw SSLPeerUnverifiedException(\"SSD service identity pin mismatch\")" + " if (connection is HttpsURLConnection && config.spkiSha256 != null) {" + " val certificate = connection.serverCertificates.firstOrNull()" + " ?: throw SSLPeerUnverifiedException(\"Server provided no certificate\")" + " val actualPin = MessageDigest.getInstance(\"SHA-256\").digest(certificate.publicKey.encoded)" + " if (!MessageDigest.isEqual(actualPin, config.spkiSha256)) {" + " connection.disconnect()" + " throw SSLPeerUnverifiedException(\"SSD service identity pin mismatch\")" + " }" " }" " return connection" " }" "" " private fun pinnedSocketFactory(config: RemoteConfig): SSLSocketFactory {" + " val expectedPin = config.spkiSha256 ?: throw CertificateException(\"SPKI pin is required for pinned TLS\")" " val managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())" " managerFactory.init(null as KeyStore?)" " val platform = managerFactory.trustManagers.filterIsInstance<X509TrustManager>().single()" @@ -3569,7 +3603,7 @@ " platform.checkServerTrusted(chain, authType)" " val leaf = chain.firstOrNull() ?: throw CertificateException(\"Server provided no certificate\")" " val actual = MessageDigest.getInstance(\"SHA-256\").digest(leaf.publicKey.encoded)" - " if (!MessageDigest.isEqual(actual, config.spkiSha256)) {" + " if (!MessageDigest.isEqual(actual, expectedPin)) {" " throw CertificateException(\"SSD service identity pin mismatch\")" " }" " }" @@ -3648,7 +3682,7 @@ " }" " }" "" - " private fun readResponseText(connection: HttpsURLConnection): String {" + " private fun readResponseText(connection: HttpURLConnection): String {" " if (connection.contentLengthLong > MAX_HTTP_COMPRESSED_BYTES) {" " throw IllegalStateException(\"HTTP response exceeds compressed-byte limit\")" " }"