Skip to content

feat(security): encrypt API keys at rest using AES-GCM#264

Merged
d-oit merged 1 commit into
mainfrom
feat/encrypt-api-keys-at-rest
Jun 5, 2026
Merged

feat(security): encrypt API keys at rest using AES-GCM#264
d-oit merged 1 commit into
mainfrom
feat/encrypt-api-keys-at-rest

Conversation

@d-oit
Copy link
Copy Markdown
Owner

@d-oit d-oit commented Jun 5, 2026

Summary

Implements issue #238 — encrypts LLM API keys at rest using AES-GCM via Web Crypto API.

Changes

  • src/lib/llm/encryption.ts (NEW): AES-GCM encryption module. Generates 256-bit key stored as JWK in localStorage. enc:v1: prefix enables migration detection.
  • src/lib/llm/config.ts: loadConfig/saveConfig are now async. Auto-encrypts on save, auto-decrypts on load, auto-migrates plaintext keys.
  • src/features/ai/AIHarness.tsx: Async config loading via useEffect on mount.
  • src/features/ai/useChat.ts: await loadConfig() in sendMessage.
  • Tests updated: All loadConfig/saveConfig calls are now async.

Security Design

  • AES-256-GCM with 12-byte random IV per encryption
  • Encryption key stored as JWK in localStorage (browser-local threat model)
  • enc:v1: prefix for forward-compatible migration
  • Legacy plaintext keys auto-migrated on first load

Validation

  • 0 lint errors ✅
  • 310 unit tests ✅
  • Typecheck clean ✅
  • Code reviewer approved ✅

Closes #238

- Add src/lib/llm/encryption.ts: AES-GCM encryption via Web Crypto API
  - Generates 256-bit key stored as JWK in localStorage
  - encryptApiKey() returns base64 IV+ciphertext with enc:v1: prefix
  - decryptApiKey() handles both encrypted and legacy plaintext (migration)
  - isEncrypted() checks prefix for migration detection
- Update config.ts: loadConfig/saveConfig are now async
  - Decrypts all provider API keys on load
  - Encrypts API keys before persisting
  - Auto-migrates plaintext keys on first load
- Simplify maskApiKey to remove redundant logic
- Update AIHarness.tsx: async config loading via useEffect
- Update useChat.ts: await loadConfig()
- Update all tests to handle async loadConfig/saveConfig

Closes #238

Co-authored-by: d-oit <6849456+d-oit@users.noreply.github.com>
@github-actions github-actions Bot added config tests Related to automated/manual tests labels Jun 5, 2026
@deepsource-io
Copy link
Copy Markdown

deepsource-io Bot commented Jun 5, 2026

DeepSource Code Review

We reviewed changes in 55fd03e...3ad35a2 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
JavaScript Jun 5, 2026 2:17p.m. Review ↗
Python Jun 5, 2026 2:17p.m. Review ↗
Shell Jun 5, 2026 2:17p.m. Review ↗
SQL Jun 5, 2026 2:17p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@d-oit d-oit merged commit eb378d9 into main Jun 5, 2026
20 of 22 checks passed
@d-oit d-oit deleted the feat/encrypt-api-keys-at-rest branch June 5, 2026 14:17

// Load persisted config (with decrypted keys) on mount
useEffect(() => {
void loadConfig().then(setConfig);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

),
};
saveConfig(updated);
void saveConfig(updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

),
};
saveConfig(updated);
void saveConfig(updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

},
}),
saveConfig: vi.fn(),
saveConfig: vi.fn().mockResolvedValue(undefined),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove redundant `undefined` from function call


When an argument is omitted from a function call, it will default to undefined. It is therefore redundant to explicitly pass an undefined literal as the last argument.

saveConfig(config);
await saveConfig(config);

const stored = JSON.parse(localStorage.getItem('dks:llm-config')!) as { activeProvider: string; providers: { openrouter: { apiKey: string } } };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forbidden non-null assertion


Using non-null assertions cancels out the benefits of strict null-checking, and introduces the possibility of runtime errors. Avoid non-null assertions unless absolutely necessary. If you still need to use one, write a skipcq comment to explain why it is safe.

Comment thread src/lib/llm/config.ts
Comment on lines 106 to 109
export function maskApiKey(key: string): string {
if (!key || key.length < 8) return key ? `...${key.slice(-4)}` : '';
if (!key) return '';
return `...${key.slice(-4)}`;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Comment thread src/lib/llm/encryption.ts
Comment on lines +8 to +33
async function getKey(): Promise<CryptoKey> {
const stored = localStorage.getItem(ENCRYPTION_KEY_STORAGE);
if (stored) {
try {
return await crypto.subtle.importKey(
'jwk',
JSON.parse(stored) as JsonWebKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);
} catch {
// Key is corrupted, generate a new one
localStorage.removeItem(ENCRYPTION_KEY_STORAGE);
}
}

const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);
const exported = await crypto.subtle.exportKey('jwk', key);
localStorage.setItem(ENCRYPTION_KEY_STORAGE, JSON.stringify(exported));
return key;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Comment thread src/lib/llm/encryption.ts
Comment on lines +39 to +55
export async function encryptApiKey(plaintext: string): Promise<string> {
const key = await getKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded,
);

// Combine IV + ciphertext and base64 encode
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), iv.length);

return ENCRYPTED_PREFIX + btoa(String.fromCharCode(...combined));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Comment thread src/lib/llm/encryption.ts
Comment on lines +62 to +82
export async function decryptApiKey(encrypted: string): Promise<string> {
if (!encrypted.startsWith(ENCRYPTED_PREFIX)) {
// Legacy plaintext key — return as-is for migration
return encrypted;
}

const key = await getKey();
const base64 = encrypted.slice(ENCRYPTED_PREFIX.length);
const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));

const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);

const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext,
);

return new TextDecoder().decode(decrypted);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Comment thread src/lib/llm/encryption.ts
Comment on lines +87 to +89
export function isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTED_PREFIX);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

@codacy-production
Copy link
Copy Markdown
Contributor

Not up to standards ⛔

🔴 Issues 1 high

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
Security 1 high

View in Codacy

🟢 Metrics 8 complexity · 0 duplication

Metric Results
Complexity 8
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

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

Labels

config tests Related to automated/manual tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] Encrypt API keys at rest and fix SSRF

2 participants