Skip to content

fix: migrate API keys from plaintext XML to IntelliJ PasswordSafe#1046

Open
stephanj wants to merge 2 commits into
devoxx:masterfrom
stephanj:fix/secure-credentials
Open

fix: migrate API keys from plaintext XML to IntelliJ PasswordSafe#1046
stephanj wants to merge 2 commits into
devoxx:masterfrom
stephanj:fix/secure-credentials

Conversation

@stephanj
Copy link
Copy Markdown
Collaborator

Move all 19 credential fields (OpenAI, Anthropic, Mistral, Groq, Gemini, DeepSeek, OpenRouter, Grok, Kimi, GLM, Azure OpenAI, AWS access/secret/bearer, CustomOpenAI, Google Search, Google CSE, Tavily) from plaintext DevoxxGenieSettingsPlugin.xml into IntelliJ's PasswordSafe (OS keychain / KeePass / KWallet).

Changes

  • New service/credentials package: CredentialKey enum (19 constants), CredentialService interface, PasswordSafeCredentialService impl with in-memory fallback for headless / test environments.
  • DevoxxGenieStateService: Lombok accessors disabled on the 19 credential fields; hand-written @Transient accessors delegate to CredentialService; one-shot migration in loadState reads legacy plaintext from the freshly-deserialised state, writes it to PasswordSafe, and wipes the XML fields. isAwsEnabled / isAzureOpenAIEnabled switched to use getters so they see the PasswordSafe-backed values.
  • CredentialMigrationStartupActivity: belt-and-braces retry on every project open; idempotent via the new `credentialsMigratedV1` flag.
  • `DevoxxGenieSettingsService` interface gap fix: add missing getter/setter pairs for `grokKey`, `kimiKey`, `glmKey`, `customOpenAIApiKey`.
  • `plugin.xml`: register `CredentialService` and the new startup activity.

Security

Credentials no longer hit `DevoxxGenieSettingsPlugin.xml`. Existing installs are auto-migrated on first launch. Downgrading to an older plugin hides the keys (they live in PasswordSafe only) — documented caveat.

Compatibility

All public getter/setter signatures on `DevoxxGenieStateService` are preserved. `LLMProviderService`, `BedrockAuthResolver`, all settings UI panels, and the ~20 test classes that mock the key getters need no code change.

Testing

`./gradlew test` — 2687 tests, 0 failures. `./gradlew buildPlugin` — green.

⚠️ This PR may show extra diffs because the source fork is 37 commits behind `devoxx/master`. The substantive changes are limited to the 7 files in commit d4fe4de: `service/credentials/*`, `service/DevoxxGenieSettingsService.java`, `ui/settings/DevoxxGenieStateService.java`, `startup/CredentialMigrationStartupActivity.java`, `META-INF/plugin.xml`. Sandbox environment lacks GitHub `workflow` scope, which prevents syncing the fork's `master` with upstream.

stephanj added 2 commits May 19, 2026 18:06
Move all 19 credential fields (OpenAI, Anthropic, Mistral, Groq, Gemini,
DeepSeek, OpenRouter, Grok, Kimi, GLM, Azure OpenAI, AWS access/secret/
bearer, CustomOpenAI, Google Search, Google CSE, Tavily) out of the
plaintext DevoxxGenieSettingsPlugin.xml and into IntelliJ's PasswordSafe
(OS keychain / KeePass / KWallet).

Changes:
- New service/credentials package: CredentialKey (19 enum constants),
  CredentialService interface, PasswordSafeCredentialService impl with
  in-memory fallback for headless/test environments.
- DevoxxGenieStateService: Lombok accessors disabled on the 19 credential
  fields; hand-written @transient accessors delegate to CredentialService;
  one-shot migration in loadState reads legacy plaintext values, writes
  them to PasswordSafe, and wipes the XML fields.
- CredentialMigrationStartupActivity: belt-and-braces retry on every
  project open; idempotent via the credentialsMigratedV1 flag.
- isAwsEnabled / isAzureOpenAIEnabled now read through getters so they
  see the PasswordSafe-backed values instead of empty legacy fields.
- DevoxxGenieSettingsService interface gap fix: add missing
  getter/setter pairs for grokKey, kimiKey, glmKey, customOpenAIApiKey.
- plugin.xml: register CredentialService and the new startup activity.

Backward compatibility:
- Public getter/setter signatures on DevoxxGenieStateService are
  preserved; LLMProviderService, BedrockAuthResolver, the settings UI
  panels and ~20 test classes need no code change.
- Existing keys are auto-migrated on first launch after upgrade.
- Downgrading to an older plugin version hides the keys (they live in
  PasswordSafe only) so the user has to re-enter them.

Tested: ./gradlew test (2687 tests, 0 failures) and ./gradlew buildPlugin
both green.
…safety

Address reviewer feedback on PR devoxx#1046:

Bug devoxx#1 (CRITICAL): legacy XML keys silently lost on upgrade.
  IntelliJ's BeanBinding/XmlSerializer treats @transient on the getter as
  "skip this property in both directions". With the previous setup
  (private field + @transient on hand-written accessor + no store
  annotation on the field) BOTH bindings were dropped, so legacy
  <option name="openAIKey" value="sk-..."/> entries were silently
  ignored and the migration read empty values.

  The reviewer's proposed fix (override getState() to null the fields on
  a copy) does not actually work for this codebase, because XmlSerializer
  uses the hand-written getter (which reads from PasswordSafe) regardless
  of the field value. Verified by reading the bytecode of
  com.intellij.serialization.PropertyCollector and
  com.intellij.util.xmlb.SkipDefaultsSerializationFilter.

  Correct fix: keep @transient on the 19 hand-written accessor pairs
  (so they are removed from the property map in
  PropertyCollector#isAcceptableProperty), AND add @OptionTag to each
  underlying field (a 'store annotation' which forces
  PropertyCollector#doCollectOwnFields to register the private field
  even with collectPrivateFields=false). With the accessor pair gone
  from the map, the field escapes the dedup loop and becomes the sole
  binding. XmlSerializer then reads & writes only the field; the
  accessors continue to route every user-facing get/set through
  PasswordSafe.

  After migration the wiped field equals the default-bean's empty value
  so the SkipDefaultsSerializationFilter omits it from the XML \u2014 no
  re-leak on save.

Bug devoxx#2 (CRITICAL): credentialsMigratedV1 set even on partial failure.
  Now gates the flag (and saveSettings()) on failed == 0; on any per-key
  failure the plaintext fields and the flag are left untouched so the
  next startup retries.

Bug devoxx#3 (HIGH): migrateCredentialsToPasswordSafe() not thread-safe.
  Marked synchronized.

Bug devoxx#4 (HIGH): PasswordSafeCredentialService#getCredential not
  synchronized while the setters are. Marked synchronized.

Medium devoxx#7: CredentialAttributes inconsistency.
  setCredential now passes null userName to new Credentials(\u2026), matching
  the null userName in CredentialKey#attributes(). Round-trip is stable
  across keychain backends.

Tested: ./gradlew test \u2014 2687 tests, 0 failures.
@stephanj
Copy link
Copy Markdown
Collaborator Author

Pushed fixes for all 4 blocking + 2 medium issues from the review.

Deviation from reviewer's Bug #1 fix — worth a second look:

The reviewer proposed "remove @Transient from getters; override getState() to null the 19 fields on a sanitised copy". After tracing the bytecode of com.intellij.serialization.PropertyCollector and com.intellij.util.xmlb.SkipDefaultsSerializationFilter in the bundled util-8.jar, that approach turns out not to work for this codebase:

  • BeanBinding prefers accessors over fields when both exist (dedup loop in PropertyCollector.doCollect keyed on accessor.getName()).
  • SkipDefaultsSerializationFilter compares property values by calling the getter on a fresh newInstance() default-bean.
  • Our hand-written getters read from PasswordSafe (a singleton). So nulling the field on the copy doesn't change what the getter returns; getOpenAIKey() would still serve the live secret to XmlSerializer, and the SkipDefaults filter would never elide it.

The fix that does work (and which I shipped):

  1. Keep @Transient on the 19 hand-written accessor pairs. PropertyCollector.isAcceptableProperty rejects them, removing the property from the map.
  2. Add @OptionTag("…") to each underlying field. @OptionTag is a store annotation, which forces PropertyCollector.doCollectOwnFields to register the private field (overriding collectPrivateFields=false). With the accessor pair already gone from the map, the field escapes the same-name dedup loop and becomes the sole binding for that property.

Net result:

  • Legacy XML <option name="openAIKey" value="sk-..."/> deserializes into the field. ✅
  • The migration in loadState reads the field, writes to PasswordSafe, wipes the field. ✅
  • After migration the field equals its empty default → SkipDefaultsSerializationFilter omits it from the saved XML. ✅
  • The public getOpenAIKey()/setOpenAIKey() continue to route every call through PasswordSafe. ✅

No getState() override needed — the binding system itself does the right thing once accessor-pair and field bindings are routed correctly.

Tests: ./gradlew test — 2687 tests, 0 failures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant