Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
Expand Down Expand Up @@ -266,6 +268,7 @@ class AiSettingsFragment : DialogFragment() {
private fun setupGeminiApiUi(view: View) {
val apiKeyLayout = view.findViewById<LinearLayout>(R.id.gemini_api_key_layout)
val apiKeyInput = view.findViewById<EditText>(R.id.gemini_api_key_input)
val toggleVisibilityButton = view.findViewById<ImageButton>(R.id.btn_toggle_api_key_visibility)
val saveButton = view.findViewById<Button>(R.id.btn_save_api_key)
val editButton = view.findViewById<Button>(R.id.btn_edit_api_key)
val clearButton = view.findViewById<Button>(R.id.btn_clear_api_key)
Expand Down Expand Up @@ -300,37 +303,69 @@ class AiSettingsFragment : DialogFragment() {
if (timestamp > 0) {
val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
val savedDate = sdf.format(Date(timestamp))
statusTextView.text = "API Key saved on: $savedDate"
statusTextView.text = getString(R.string.msg_api_key_saved_on, savedDate)
} else {
statusTextView.text = "API Key is saved"
statusTextView.text = getString(R.string.msg_api_key_is_saved)
}
}

saveButton.setOnClickListener {
val apiKey = apiKeyInput.text.toString()
if (apiKey.isNotBlank()) {
viewModel.saveGeminiApiKey(apiKey)
Toast.makeText(requireContext(), "API Key saved", Toast.LENGTH_SHORT).show()
toggleVisibilityButton.setColorFilter(apiKeyInput.currentHintTextColor)

updateUiState(isEditing = false)
val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
val savedDate = sdf.format(Date(timestamp))
statusTextView.text = "API Key saved on: $savedDate"
var isKeyVisible = false

fun applyKeyVisibility() {
apiKeyInput.transformationMethod = if (isKeyVisible) {
HideReturnsTransformationMethod.getInstance()
} else {
Toast.makeText(requireContext(), "API Key cannot be empty", Toast.LENGTH_SHORT).show()
PasswordTransformationMethod.getInstance()
}
toggleVisibilityButton.setImageResource(
if (isKeyVisible) R.drawable.ic_visibility_off else R.drawable.ic_visibility
)
toggleVisibilityButton.contentDescription = getString(
if (isKeyVisible) R.string.cd_hide_api_key else R.string.cd_show_api_key
)
toggleVisibilityButton.setColorFilter(apiKeyInput.currentHintTextColor)
apiKeyInput.setSelection(apiKeyInput.text?.length ?: 0)
}

applyKeyVisibility()

toggleVisibilityButton.setOnClickListener {
isKeyVisible = !isKeyVisible
applyKeyVisibility()
}

saveButton.setOnClickListener {
val apiKey = apiKeyInput.text.toString().trim()
if (apiKey.isBlank()) {
Toast.makeText(requireContext(), getString(R.string.msg_api_key_empty), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (!viewModel.saveGeminiApiKey(apiKey)) {
Toast.makeText(requireContext(), getString(R.string.msg_api_key_save_failed), Toast.LENGTH_LONG).show()
return@setOnClickListener
}
Toast.makeText(requireContext(), getString(R.string.msg_api_key_saved), Toast.LENGTH_SHORT).show()

updateUiState(isEditing = false)
val timestamp = viewModel.getGeminiApiKeySaveTimestamp()
val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
val savedDate = sdf.format(Date(timestamp))
statusTextView.text = getString(R.string.msg_api_key_saved_on, savedDate)
}

editButton.setOnClickListener {
updateUiState(isEditing = true)
apiKeyInput.setText("••••••••••••••••")
apiKeyInput.setText(viewModel.getGeminiApiKey().orEmpty())
isKeyVisible = false
applyKeyVisibility()
apiKeyInput.requestFocus()
}

clearButton.setOnClickListener {
viewModel.clearGeminiApiKey()
Toast.makeText(requireContext(), "API Key cleared", Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), getString(R.string.msg_api_key_cleared), Toast.LENGTH_SHORT).show()
updateUiState(isEditing = true)
apiKeyInput.setText("")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.itsaky.androidide.plugins.aiassistant.security

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

/**
* AES/GCM encryption for sensitive settings (currently the Gemini API key),
* keyed by a hardware-backed Android Keystore secret. Only ciphertext is
* written to SharedPreferences, so a copied prefs file (root, `adb backup`,
* forensic dump) is useless without this device's Keystore.
*
* The alias and transform below are mirrored verbatim in ai-core's
* `SecureApiKeyStore` so a key written here can be decrypted there — both
* plugins run in the host app's process (same UID) and therefore share one
* Android Keystore. Keep the two copies in sync.
*/
object SecureApiKeyStore {
private const val TAG = "SecureApiKeyStore"
private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS = "cotg_ai_gemini_key_v1"
private const val TRANSFORM = "AES/GCM/NoPadding"
private const val IV_LEN = 12
private const val TAG_BITS = 128

/** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */
const val ENC_PREFIX = "enc:v1:"

private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}

private fun deleteKey() {
try {
KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS)
} catch (e: Exception) {
Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e)
}
}

private fun encryptWith(key: SecretKey, plain: String): String {
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
val combined = ByteArray(iv.size + ciphertext.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP)
}

/**
* Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext).
*
* If the Keystore key has been permanently invalidated (e.g. the lock-screen credentials
* changed, or the entry is corrupt) the stale alias is dropped and a fresh key generated
* once before retrying. Any other Keystore/cipher failure is surfaced as a
* [GeneralSecurityException] so the caller can inform the user instead of crashing — the
* previous version let these propagate uncaught and take the IDE down on Save.
*/
@Throws(GeneralSecurityException::class)
fun encrypt(plain: String): String {
return try {
encryptWith(getOrCreateKey(), plain)
} catch (e: KeyPermanentlyInvalidatedException) {
Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e)
deleteKey()
encryptWith(getOrCreateKey(), plain)
}
}

/**
* Return the plaintext for a stored value, handling both formats transparently:
* an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as
* legacy plaintext (it gets migrated to ciphertext on the next save). Returns
* null if a ciphertext value can't be decrypted — e.g. the Keystore key was
* lost or invalidated — in which case the user must re-enter the key.
*/
fun decrypt(stored: String?): String? {
if (stored == null) return null
if (!stored.startsWith(ENC_PREFIX)) return stored
return try {
val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP)
val iv = combined.copyOfRange(0, IV_LEN)
val ciphertext = combined.copyOfRange(IV_LEN, combined.size)
val cipher = Cipher.getInstance(TRANSFORM)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
} catch (e: Exception) {
Log.w(TAG, "Failed to decrypt stored API key", e)
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.SharedServices
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -179,21 +180,33 @@ class AiSettingsViewModel(
}

/**
* Persists the Gemini API key in this plugin's private SharedPreferences.
* The store is app-sandboxed (not world-readable) but NOT encrypted at rest;
* it is recoverable on a rooted/compromised device. Use [clearGeminiApiKey]
* to remove it. See the plugin README "Security" section for the tradeoff.
* Persists the Gemini API key in this plugin's private SharedPreferences,
* encrypted at rest with a hardware-backed Android Keystore secret (see
* [SecureApiKeyStore]). Only ciphertext is written, so the prefs file alone
* (root, `adb backup`, forensic dump) does not disclose the key. Use
* [clearGeminiApiKey] to remove it.
*
* Returns false (persisting nothing) if encryption fails — e.g. a hardware Keystore
* fault the automatic key-regeneration retry couldn't recover — so the caller can warn
* the user instead of the whole IDE crashing on Save.
*/
fun saveGeminiApiKey(apiKey: String) {
fun saveGeminiApiKey(apiKey: String): Boolean {
val encrypted = try {
SecureApiKeyStore.encrypt(apiKey)
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to encrypt Gemini API key", e)
return false
}
getPluginPrefs()?.edit()?.apply {
putString("gemini_api_key", apiKey)
putString("gemini_api_key", encrypted)
putLong("gemini_api_key_timestamp", System.currentTimeMillis())
apply()
}
return true
}

fun getGeminiApiKey(): String? {
return getPluginPrefs()?.getString("gemini_api_key", null)
return SecureApiKeyStore.decrypt(getPluginPrefs()?.getString("gemini_api_key", null))
}

fun getGeminiApiKeySaveTimestamp(): Long {
Expand Down
10 changes: 10 additions & 0 deletions ai-assistant/src/main/res/drawable/ic_visibility.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>
10 changes: 10 additions & 0 deletions ai-assistant/src/main/res/drawable/ic_visibility_off.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</vector>
30 changes: 25 additions & 5 deletions ai-assistant/src/main/res/layout/layout_settings_gemini_api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,33 @@
android:textAppearance="?android:attr/textAppearanceSmall"
android:paddingBottom="4dp"/>

<EditText
android:id="@+id/gemini_api_key_input"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:hint="Enter your Gemini API key"
android:maxLines="1" />
android:gravity="center_vertical"
android:orientation="horizontal">

<EditText
android:id="@+id/gemini_api_key_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:inputType="textPassword"
android:hint="@string/hint_gemini_api_key"
android:maxLines="1" />

<ImageButton
android:id="@+id/btn_toggle_api_key_visibility"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/cd_show_api_key"
android:minWidth="0dp"
android:minHeight="0dp"
android:padding="4dp"
android:src="@drawable/ic_visibility" />
</LinearLayout>
</LinearLayout>

<LinearLayout
Expand Down
9 changes: 9 additions & 0 deletions ai-assistant/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@

<!-- Settings -->
<string name="settings_title">AI Settings</string>
<string name="cd_show_api_key">Show API key</string>
<string name="cd_hide_api_key">Hide API key</string>
<string name="hint_gemini_api_key">Enter your Gemini API key</string>
<string name="msg_api_key_saved">API Key saved</string>
<string name="msg_api_key_saved_on">API Key saved on: %s</string>
<string name="msg_api_key_is_saved">API Key is saved</string>
<string name="msg_api_key_empty">API Key cannot be empty</string>
<string name="msg_api_key_cleared">API Key cleared</string>
<string name="msg_api_key_save_failed">Couldn\'t save the API key on this device. Please try again.</string>
<string name="settings_backend">Backend</string>
<string name="settings_model">Model</string>
<string name="settings_temperature">Temperature</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,17 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
context.logger.error("GeminiBackend: Error getting preferences", e)
null
}
return prefs?.getString("gemini_api_key", null)?.trim()?.takeIf { it.isNotBlank() }
val stored = prefs?.getString("gemini_api_key", null)
return SecureApiKeyStore.decrypt(stored)?.trim()?.takeIf { it.isNotBlank() }
}

override fun getId(): String = "gemini"

override fun getName(): String = "Gemini API"

override fun isAvailable(): Boolean {
// Check if API key is configured
val prefs = try {
// Get ai-assistant plugin's preferences
val aiAssistantContext = SharedServices.get(PluginContext::class.java)
aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
} catch (e: Exception) {
context.logger.error("GeminiBackend: Error getting preferences", e)
null
}

val apiKey = prefs?.getString("gemini_api_key", null)
// Check if a (decryptable) API key is configured
val apiKey = readGeminiApiKey()
context.logger.debug("GeminiBackend.isAvailable() - API key configured: ${!apiKey.isNullOrBlank()}")

return !apiKey.isNullOrBlank()
Expand Down Expand Up @@ -406,23 +398,15 @@ class GeminiBackend(private val context: PluginContext) : LlmBackend {
* Create Gemini client with API key from settings.
*/
private fun createClient(): Client? {
val prefs = try {
val aiAssistantContext = SharedServices.get(PluginContext::class.java)
aiAssistantContext?.getPluginSharedPreferences("AgentSettings")
} catch (e: Exception) {
context.logger.error("GeminiBackend: Error getting preferences", e)
return null
}

val apiKey = prefs?.getString("gemini_api_key", null)
val apiKey = readGeminiApiKey()
if (apiKey.isNullOrBlank()) {
context.logger.error("GeminiBackend: API key not found")
return null
}

return try {
Client.builder()
.apiKey(apiKey.trim())
.apiKey(apiKey)
.build()
} catch (e: Exception) {
context.logger.error("GeminiBackend: Error creating client", e)
Expand Down
Loading