|
1 | 1 | package com.itsaky.androidide.plugins.aiagentgemini.security |
2 | 2 |
|
3 | | -import android.content.SharedPreferences |
4 | | -import android.security.keystore.KeyGenParameterSpec |
5 | | -import android.security.keystore.KeyPermanentlyInvalidatedException |
6 | | -import android.security.keystore.KeyProperties |
7 | | -import android.util.Base64 |
8 | | -import android.util.Log |
9 | | -import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX |
10 | | -import java.security.GeneralSecurityException |
11 | | -import java.security.KeyStore |
12 | | -import javax.crypto.Cipher |
13 | | -import javax.crypto.KeyGenerator |
14 | | -import javax.crypto.SecretKey |
15 | | -import javax.crypto.spec.GCMParameterSpec |
| 3 | +import com.itsaky.androidide.plugins.security.KeystoreSecretStore |
| 4 | + |
| 5 | +/** Unique to this plugin and fixed across releases; see [KeystoreSecretStore] for why both matter. */ |
| 6 | +private const val ALIAS = "cotg_ai_gemini_key_v1" |
16 | 7 |
|
17 | 8 | /** |
18 | | - * AES/GCM encryption for sensitive settings (currently the Gemini API key), |
19 | | - * keyed by a hardware-backed Android Keystore secret. Only ciphertext is |
20 | | - * written to SharedPreferences, so a copied prefs file (root, `adb backup`, |
21 | | - * forensic dump) is useless without this device's Keystore. |
| 9 | + * This plugin's binding of [KeystoreSecretStore]: its API key, encrypted under this plugin's own |
| 10 | + * Keystore alias. |
22 | 11 | * |
23 | | - * The [ALIAS] must stay stable across releases: a key encrypted under one |
24 | | - * alias cannot be read under another, so changing it silently invalidates |
25 | | - * every stored key. It is also what lets a key written before the AI plugins |
26 | | - * were reorganised still decrypt today — every plugin runs in the host app's |
27 | | - * process and UID, so they all share one Android Keystore. |
| 12 | + * The store is the IDE's, from plugin-api, and callers use it directly. A forwarding object per |
| 13 | + * method would only be a second copy of its contract to keep in step — and one that had to pick a |
| 14 | + * single answer for "absent" and "no longer decryptable", which callers here do not share. The |
| 15 | + * thing this file owns is the alias. |
28 | 16 | */ |
29 | | -object SecureApiKeyStore { |
30 | | - private const val TAG = "$LOG_PREFIX.SecureApiKeyStore" |
31 | | - private const val KEYSTORE = "AndroidKeyStore" |
32 | | - private const val ALIAS = "cotg_ai_gemini_key_v1" |
33 | | - private const val TRANSFORM = "AES/GCM/NoPadding" |
34 | | - private const val IV_LEN = 12 |
35 | | - private const val TAG_BITS = 128 |
36 | | - |
37 | | - /** Marks a stored value as ciphertext; anything without it is treated as legacy plaintext. */ |
38 | | - const val ENC_PREFIX = "enc:v1:" |
39 | | - |
40 | | - private fun getOrCreateKey(): SecretKey { |
41 | | - val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } |
42 | | - (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } |
43 | | - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) |
44 | | - generator.init( |
45 | | - KeyGenParameterSpec.Builder( |
46 | | - ALIAS, |
47 | | - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT |
48 | | - ) |
49 | | - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) |
50 | | - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) |
51 | | - .build() |
52 | | - ) |
53 | | - return generator.generateKey() |
54 | | - } |
55 | | - |
56 | | - private fun deleteKey() { |
57 | | - try { |
58 | | - KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(ALIAS) |
59 | | - } catch (e: Exception) { |
60 | | - Log.w(TAG, "Failed to delete Keystore alias $ALIAS", e) |
61 | | - } |
62 | | - } |
63 | | - |
64 | | - private fun encryptWith(key: SecretKey, plain: String): String { |
65 | | - val cipher = Cipher.getInstance(TRANSFORM) |
66 | | - cipher.init(Cipher.ENCRYPT_MODE, key) |
67 | | - val iv = cipher.iv |
68 | | - val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) |
69 | | - val combined = ByteArray(iv.size + ciphertext.size) |
70 | | - System.arraycopy(iv, 0, combined, 0, iv.size) |
71 | | - System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size) |
72 | | - return ENC_PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP) |
73 | | - } |
74 | | - |
75 | | - /** |
76 | | - * Encrypt [plain] into a self-describing string: [ENC_PREFIX] + base64(iv | ciphertext). |
77 | | - * |
78 | | - * The key is not auth-bound, so a credential change does not invalidate it; an alias an |
79 | | - * OEM Keystore drops anyway is regenerated once before retrying. |
80 | | - * |
81 | | - * @param plain the value to encrypt |
82 | | - * @throws GeneralSecurityException on any other Keystore/cipher failure, so the caller can |
83 | | - * inform the user instead of crashing the IDE on Save |
84 | | - */ |
85 | | - @Throws(GeneralSecurityException::class) |
86 | | - fun encrypt(plain: String): String { |
87 | | - return try { |
88 | | - encryptWith(getOrCreateKey(), plain) |
89 | | - } catch (e: KeyPermanentlyInvalidatedException) { |
90 | | - Log.w(TAG, "Keystore key invalidated; regenerating and retrying encrypt", e) |
91 | | - deleteKey() |
92 | | - encryptWith(getOrCreateKey(), plain) |
93 | | - } |
94 | | - } |
95 | | - |
96 | | - /** |
97 | | - * Return the plaintext for a stored value, handling both formats transparently: |
98 | | - * an [ENC_PREFIX] value is decrypted; anything else is returned unchanged as |
99 | | - * legacy plaintext (use [readAndMigrate] to upgrade it in place). Returns |
100 | | - * null if a ciphertext value can't be decrypted — e.g. the Keystore key was |
101 | | - * lost or invalidated — in which case the user must re-enter the key. |
102 | | - */ |
103 | | - fun decrypt(stored: String?): String? { |
104 | | - if (stored == null) return null |
105 | | - if (!stored.startsWith(ENC_PREFIX)) return stored |
106 | | - return try { |
107 | | - val combined = Base64.decode(stored.removePrefix(ENC_PREFIX), Base64.NO_WRAP) |
108 | | - val iv = combined.copyOfRange(0, IV_LEN) |
109 | | - val ciphertext = combined.copyOfRange(IV_LEN, combined.size) |
110 | | - val cipher = Cipher.getInstance(TRANSFORM) |
111 | | - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) |
112 | | - String(cipher.doFinal(ciphertext), Charsets.UTF_8) |
113 | | - } catch (e: Exception) { |
114 | | - Log.w(TAG, "Failed to decrypt stored API key", e) |
115 | | - null |
116 | | - } |
117 | | - } |
118 | | - |
119 | | - /** |
120 | | - * Read [key] from [prefs], upgrading a legacy plaintext value to ciphertext in place. |
121 | | - * |
122 | | - * Keys written before this store existed are still plaintext on disk, and [decrypt] alone |
123 | | - * hands them back unchanged forever — so an install that configured its key earlier would |
124 | | - * never actually gain encryption. Re-encrypting on the first read closes that gap without |
125 | | - * making the user re-enter the key. |
126 | | - * |
127 | | - * The value is trimmed on migration, so the stored, displayed and sent forms all agree. |
128 | | - * |
129 | | - * Keystore IPC + AES/GCM, so call this off the main thread. |
130 | | - * |
131 | | - * @return the trimmed plaintext value, or null when nothing is stored or decryption failed. |
132 | | - */ |
133 | | - fun readAndMigrate(prefs: SharedPreferences?, key: String): String? { |
134 | | - val stored = prefs?.getString(key, null) ?: return null |
135 | | - if (stored.startsWith(ENC_PREFIX)) return decrypt(stored) |
136 | | - val plain = stored.trim() |
137 | | - if (plain.isEmpty()) return plain |
138 | | - try { |
139 | | - prefs.edit().putString(key, encrypt(plain)).apply() |
140 | | - Log.i(TAG, "Upgraded legacy plaintext value for '$key' to ciphertext") |
141 | | - } catch (e: Exception) { |
142 | | - Log.w(TAG, "Could not upgrade legacy plaintext value for '$key' to ciphertext", e) |
143 | | - } |
144 | | - return plain |
145 | | - } |
146 | | -} |
| 17 | +val secureApiKeyStore = KeystoreSecretStore(ALIAS) |
0 commit comments