Skip to content

feat: implement secure storage unlock with password verification#144

Open
winnpxl wants to merge 6 commits intoancore-org:mainfrom
winnpxl:feat/secure-storage-unlock-116
Open

feat: implement secure storage unlock with password verification#144
winnpxl wants to merge 6 commits intoancore-org:mainfrom
winnpxl:feat/secure-storage-unlock-116

Conversation

@winnpxl
Copy link
Copy Markdown

@winnpxl winnpxl commented Mar 24, 2026

feat: Implement Secure Storage Unlock (#116)
Implements SecureStorageManager.unlock(password) returning Promise.

Generates and persists a master salt on first run, loads it on subsequent runs
Derives AES-256-GCM encryption key via PBKDF2 (100k iterations, SHA-256)
Verifies password via a lightweight decrypt of a stored verification payload
Returns true for correct password, false for wrong — no plaintext exposed
Encryption key kept in memory only, cleared immediately on failed verification
108 tests passing across 3 test suites

Closes #116

Summary by CodeRabbit

  • New Features

    • Unlock method now returns a boolean indicating whether password authentication succeeds or fails, enabling better error handling and immediate feedback
  • Bug Fixes

    • Enhanced unlock idempotency—already-unlocked storage returns immediately without redundant reprocessing
    • Improved authentication failure handling with automatic state cleanup and consistent error responses

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements secure storage unlock functionality, adding password-based encryption key derivation with master salt management, password verification through lightweight decryption, and returning a boolean to indicate success or failure. It includes comprehensive test coverage for salt lifecycle, key derivation correctness, verification payloads, and security guarantees.

Changes

Cohort / File(s) Summary
Test Updates
packages/core-sdk/src/storage/__tests__/manager.test.ts
Modified unlock-related tests to capture and assert manager.unlock() returns true for successful unlocks; updated wrong-password test to verify unlock('wrong_password') returns false and error message changes from "Invalid password or corrupted data" to "Storage manager is locked".
New Unlock Test Suite
packages/core-sdk/src/storage/__tests__/unlock.test.ts
Added comprehensive 1405-line test module covering master salt lifecycle (initialization, loading, corruption detection), key derivation consistency, verification payload creation/validation, encryption/decryption interoperability, security guarantees (no key material persistence), error-message safety, and backward compatibility scenarios.
Core Implementation
packages/core-sdk/src/storage/secure-storage-manager.ts
Changed unlock() signature from Promise<void> to Promise<boolean>, renamed internal field baseKey to encryptionKey, implemented two-phase unlock flow (load/generate master salt, derive key, create/verify payload), added master-salt management methods (initializeMasterSalt, loadMasterSalt), and enhanced error handling to return false on password verification failure.
Type Safety
packages/core-sdk/src/storage/types.ts
Narrowed StorageAdapter methods and AccountData/SessionKeysData index signatures from any to unknown for stricter type checking.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Manager as SecureStorageManager
    participant Crypto as Web Crypto API
    participant Storage as StorageAdapter

    rect rgba(70, 130, 180, 0.5)
    Note over Client,Storage: First Unlock (New Storage)
    Client->>Manager: unlock(password)
    Manager->>Storage: get('master_salt')
    Storage-->>Manager: null
    Manager->>Crypto: getRandomValues()
    Crypto-->>Manager: 16-byte random salt
    Manager->>Manager: initializeMasterSalt()
    Manager->>Crypto: PBKDF2 derive key
    Crypto-->>Manager: encryptionKey (CryptoKey)
    Manager->>Manager: createVerificationPayload()
    Manager->>Crypto: AES-GCM encrypt
    Crypto-->>Manager: encrypted payload
    Manager->>Storage: set('master_salt', base64_salt)
    Storage-->>Manager: ✓
    Manager->>Storage: set('verification_payload', encrypted)
    Storage-->>Manager: ✓
    Manager-->>Client: true
    end

    rect rgba(144, 238, 144, 0.5)
    Note over Client,Storage: Subsequent Unlock (Password Verify)
    Client->>Manager: unlock(password)
    Manager->>Storage: get('master_salt')
    Storage-->>Manager: base64_salt
    Manager->>Manager: loadMasterSalt()
    Manager->>Manager: Master salt decoded to Uint8Array
    Manager->>Crypto: PBKDF2 derive key
    Crypto-->>Manager: encryptionKey (CryptoKey)
    Manager->>Storage: get('verification_payload')
    Storage-->>Manager: encrypted_payload
    Manager->>Crypto: AES-GCM decrypt
    alt Decryption Success
        Crypto-->>Manager: plaintext (KIRO_VERIFICATION_V1...)
        Manager-->>Client: true
    else Wrong Password / Corrupted Data
        Crypto-->>Manager: DecryptionError
        Manager->>Manager: Clear encryptionKey
        Manager-->>Client: false
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • feat: Add Secure Storage Manager #127: This PR significantly modifies the SecureStorageManager introduced in PR #127 by changing the unlock() signature from Promise<void> to Promise<boolean>, renaming baseKey to encryptionKey, and adding the master-salt and verification-payload workflows.

Poem

🐰 A key is born from phrase and salt,
No plaintext lingers in the vault,
With true or false, we guard the door,
Encryption guards what came before. ✨🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main feature: implementing secure storage unlock with password verification.
Linked Issues check ✅ Passed All coding requirements from issue #116 are met: unlock derives encryption key via PBKDF2/SHA-256, loads/creates master salt, keeps key in-memory only, verifies password via verification payload decryption, returns false on wrong password with no plaintext exposure, and includes comprehensive unit tests.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #116 objectives. Type narrowing in types.ts and test improvements in manager.test.ts support the unlock implementation and are appropriate refactoring for the feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@drips-wave
Copy link
Copy Markdown

drips-wave bot commented Mar 24, 2026

@winnpxl Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (3)
packages/core-sdk/src/storage/__tests__/manager.test.ts (1)

17-35: Consider typing MockStorageAdapter more strictly for better test documentation.

While any is acceptable in tests (ESLint rule is disabled), using stricter types could serve as documentation and catch test bugs earlier.

♻️ Optional: Stricter MockStorageAdapter typing
 class MockStorageAdapter implements StorageAdapter {
-  private store: Map<string, any> = new Map();
+  private store: Map<string, unknown> = new Map();

-  async get(key: string): Promise<any> {
+  async get(key: string): Promise<unknown> {
     return this.store.get(key) || null;
   }

-  async set(key: string, value: any): Promise<void> {
+  async set(key: string, value: unknown): Promise<void> {
     this.store.set(key, value);
   }

   async remove(key: string): Promise<void> {
     this.store.delete(key);
   }

-  public inspectStore(): Map<string, any> {
+  public inspectStore(): Map<string, unknown> {
     return this.store;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/manager.test.ts` around lines 17 -
35, The MockStorageAdapter is using broad any types; tighten its typing to
document behavior and catch errors by replacing Map<string, any> with
Map<string, unknown> (or a concrete value type used in tests) and updating
method signatures on MockStorageAdapter (get, set, remove, inspectStore) to use
that same type instead of any so the mock matches StorageAdapter's contract
(e.g., change get to Promise<unknown>, set to (key: string, value: unknown):
Promise<void>, and inspectStore to return Map<string, unknown>), ensuring
consistency with the StorageAdapter interface.
packages/core-sdk/eslint.config.cjs (1)

15-32: Consider extracting shared globals to reduce duplication.

The globals configuration (Buffer, process, TextEncoder, TextDecoder, CryptoKey, BufferSource) is duplicated between the TypeScript config (lines 15-24) and test file config (lines 42-51). This could be extracted into a shared object to improve maintainability.

♻️ Proposed refactor to extract shared globals
 const js = require('@eslint/js');
 const tseslint = require('@typescript-eslint/eslint-plugin');
 const tsparser = require('@typescript-eslint/parser');

+const sharedGlobals = {
+  // Node.js globals
+  Buffer: 'readonly',
+  process: 'readonly',
+  // Web Crypto API globals
+  TextEncoder: 'readonly',
+  TextDecoder: 'readonly',
+  CryptoKey: 'readonly',
+  BufferSource: 'readonly',
+};
+
 module.exports = [
   js.configs.recommended,
   {
     files: ['**/*.ts'],
     languageOptions: {
       parser: tsparser,
       parserOptions: {
         ecmaVersion: 2020,
         sourceType: 'module',
       },
-      globals: {
-        // Node.js globals
-        Buffer: 'readonly',
-        process: 'readonly',
-        // Web Crypto API globals
-        TextEncoder: 'readonly',
-        TextDecoder: 'readonly',
-        CryptoKey: 'readonly',
-        BufferSource: 'readonly',
-      },
+      globals: sharedGlobals,
     },

Then for test files:

       globals: {
-        // Node.js globals
-        Buffer: 'readonly',
-        process: 'readonly',
-        require: 'readonly',
-        // Web Crypto API globals
-        TextEncoder: 'readonly',
-        TextDecoder: 'readonly',
-        CryptoKey: 'readonly',
-        BufferSource: 'readonly',
+        ...sharedGlobals,
+        require: 'readonly',
         // Jest globals
         describe: 'readonly',

Also applies to: 42-61

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/eslint.config.cjs` around lines 15 - 32, Extract the
repeated globals object into a single shared constant (e.g., SHARED_GLOBALS) and
reuse it in both places that currently define globals; replace the inline
globals block (which lists Buffer, process, TextEncoder, TextDecoder, CryptoKey,
BufferSource) with a spread of SHARED_GLOBALS in the existing globals objects so
both the TypeScript config and the test-file config reference the same constant;
ensure the new constant is exported/available to both config sections and keep
the existing '@typescript-eslint' plugin and rules untouched.
packages/core-sdk/src/storage/__tests__/unlock.test.ts (1)

17-35: Consider using unknown instead of any in MockStorageAdapter.

The PR objectives mention replacing some any types with unknown. The mock adapter could align with this by using Map<string, unknown> and Promise<unknown> return types, matching the StorageAdapter interface definition.

♻️ Proposed refactor for type consistency
-class MockStorageAdapter implements StorageAdapter {
-  private store: Map<string, any> = new Map();
+class MockStorageAdapter implements StorageAdapter {
+  private store: Map<string, unknown> = new Map();

-  async get(key: string): Promise<any> {
+  async get(key: string): Promise<unknown> {
     return this.store.get(key) || null;
   }

-  async set(key: string, value: any): Promise<void> {
+  async set(key: string, value: unknown): Promise<void> {
     this.store.set(key, value);
   }

   async remove(key: string): Promise<void> {
     this.store.delete(key);
   }

-  public inspectStore(): Map<string, any> {
+  public inspectStore(): Map<string, unknown> {
     return this.store;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts` around lines 17 - 35,
Update MockStorageAdapter to use unknown instead of any: change the internal
store type to Map<string, unknown>, update get to return Promise<unknown>, set
to accept value: unknown, remove remains same, and adjust inspectStore() to
return Map<string, unknown>; ensure the class still implements StorageAdapter
and that function names (MockStorageAdapter, get, set, remove, inspectStore)
match the interface signatures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts`:
- Around line 1345-1375: The test saves old encrypted data using the old key
derivation but never verifies it can still be read after migration; update the
test to call getAccount() against the newManager after migration and assert it
returns the original oldAccountData (e.g., call newManager.getAccount() and
expect toEqual oldAccountData) before/alongside verifying the newly saved
newAccountData, ensuring SecureStorageManager.unlock(password),
manager.saveAccount(oldAccountData), and newManager.getAccount() are exercised
for backward-compatibility.
- Around line 478-492: Update the test so it also asserts that the manager's
unlocked state and in-memory key are cleared when verification payload is
missing: after calling the bound verifyPassword() and asserting the returned
result is false, add assertions that manager.isUnlocked is false and that
(manager as any).encryptionKey has been cleared (null/undefined) to match the
updated implementation of verifyPassword/deriveEncryptionKey behavior.
- Around line 1306-1343: The test "should decrypt data encrypted with old unlock
implementation" is misleading because it doesn't verify decryption of old-format
payloads; update the test to reflect what it actually asserts by renaming it
(e.g., to "should initialize master_salt and verification_payload on first
unlock when legacy data exists") and adjust inline comments to remove claims
about decrypting old data; locate the code interacting with
SecureStorageManager, manager.saveAccount, manager.lock, new
SecureStorageManager(storage), newManager.unlock(password), and the assertions
for master_salt and verification_payload and ensure they match the new intent
(or alternatively implement explicit migration logic that derives the old key
path and verifies decryption if you opt to keep the original test name).

In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 165-179: The method verifyPassword can return false when
storage.get('verification_payload') yields null but leaves this.encryptionKey
set, causing unlock()/isUnlocked to be inconsistent; update verifyPassword (the
block that handles a missing verification_payload) to clear this.encryptionKey
(set it to null) before returning false so the manager is in a locked state,
ensuring consistency with decryptData error handling and the expectations of
unlock()/isUnlocked.

In `@packages/core-sdk/src/storage/types.ts`:
- Around line 11-12: Update the MockStorageAdapter implementations used in the
tests so their method signatures match the StorageAdapter interface: change
async get(key: string): Promise<any> to async get(key: string): Promise<unknown>
and change async set(key: string, value: any): Promise<void> to async set(key:
string, value: unknown): Promise<void>; specifically edit the MockStorageAdapter
declarations referenced in unlock.test and manager.test and adjust any test code
that assumed `any` (add type narrowing or assertions where the mock return
values are consumed) so the tests compile against the new unknown types.

---

Nitpick comments:
In `@packages/core-sdk/eslint.config.cjs`:
- Around line 15-32: Extract the repeated globals object into a single shared
constant (e.g., SHARED_GLOBALS) and reuse it in both places that currently
define globals; replace the inline globals block (which lists Buffer, process,
TextEncoder, TextDecoder, CryptoKey, BufferSource) with a spread of
SHARED_GLOBALS in the existing globals objects so both the TypeScript config and
the test-file config reference the same constant; ensure the new constant is
exported/available to both config sections and keep the existing
'@typescript-eslint' plugin and rules untouched.

In `@packages/core-sdk/src/storage/__tests__/manager.test.ts`:
- Around line 17-35: The MockStorageAdapter is using broad any types; tighten
its typing to document behavior and catch errors by replacing Map<string, any>
with Map<string, unknown> (or a concrete value type used in tests) and updating
method signatures on MockStorageAdapter (get, set, remove, inspectStore) to use
that same type instead of any so the mock matches StorageAdapter's contract
(e.g., change get to Promise<unknown>, set to (key: string, value: unknown):
Promise<void>, and inspectStore to return Map<string, unknown>), ensuring
consistency with the StorageAdapter interface.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts`:
- Around line 17-35: Update MockStorageAdapter to use unknown instead of any:
change the internal store type to Map<string, unknown>, update get to return
Promise<unknown>, set to accept value: unknown, remove remains same, and adjust
inspectStore() to return Map<string, unknown>; ensure the class still implements
StorageAdapter and that function names (MockStorageAdapter, get, set, remove,
inspectStore) match the interface signatures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 328e03be-8c50-4e0f-91ae-7ee91054c59d

📥 Commits

Reviewing files that changed from the base of the PR and between ff35f46 and d7b4944.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • .kiro/specs/secure-storage-unlock/.config.kiro
  • .kiro/specs/secure-storage-unlock/design.md
  • .kiro/specs/secure-storage-unlock/requirements.md
  • .kiro/specs/secure-storage-unlock/tasks.md
  • packages/core-sdk/eslint.config.cjs
  • packages/core-sdk/src/storage/__tests__/manager.test.ts
  • packages/core-sdk/src/storage/__tests__/unlock.test.ts
  • packages/core-sdk/src/storage/secure-storage-manager.ts
  • packages/core-sdk/src/storage/types.ts
  • packages/core-sdk/tsconfig.json

Comment on lines +478 to +492
it('should return false when verification payload does not exist', async () => {
// Set up encryption key directly without calling unlock (which would create the verification payload)
const password = 'test-password';
const masterSalt = new Uint8Array(16);
globalThis.crypto.getRandomValues(masterSalt);

const deriveEncryptionKey = (manager as any).deriveEncryptionKey.bind(manager);
(manager as any).encryptionKey = await deriveEncryptionKey(password, masterSalt);

// Verify password without verification payload
const verifyPassword = (manager as any).verifyPassword.bind(manager);
const result = await verifyPassword();

expect(result).toBe(false);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Test should verify isUnlocked state when verification payload is missing.

This test verifies that verifyPassword() returns false when the payload doesn't exist, but doesn't verify that the encryption key is cleared. Based on the current implementation, isUnlocked would remain true after this returns false, which is inconsistent behavior.

If the implementation is updated to clear the key in this case, this test should also assert expect(manager.isUnlocked).toBe(false).

💚 Proposed test enhancement
     it('should return false when verification payload does not exist', async () => {
       // Set up encryption key directly without calling unlock (which would create the verification payload)
       const password = 'test-password';
       const masterSalt = new Uint8Array(16);
       globalThis.crypto.getRandomValues(masterSalt);
       
       const deriveEncryptionKey = (manager as any).deriveEncryptionKey.bind(manager);
       (manager as any).encryptionKey = await deriveEncryptionKey(password, masterSalt);
       
       // Verify password without verification payload
       const verifyPassword = (manager as any).verifyPassword.bind(manager);
       const result = await verifyPassword();
       
       expect(result).toBe(false);
+      // Key should be cleared when verification payload is missing
+      expect(manager.isUnlocked).toBe(false);
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should return false when verification payload does not exist', async () => {
// Set up encryption key directly without calling unlock (which would create the verification payload)
const password = 'test-password';
const masterSalt = new Uint8Array(16);
globalThis.crypto.getRandomValues(masterSalt);
const deriveEncryptionKey = (manager as any).deriveEncryptionKey.bind(manager);
(manager as any).encryptionKey = await deriveEncryptionKey(password, masterSalt);
// Verify password without verification payload
const verifyPassword = (manager as any).verifyPassword.bind(manager);
const result = await verifyPassword();
expect(result).toBe(false);
});
it('should return false when verification payload does not exist', async () => {
// Set up encryption key directly without calling unlock (which would create the verification payload)
const password = 'test-password';
const masterSalt = new Uint8Array(16);
globalThis.crypto.getRandomValues(masterSalt);
const deriveEncryptionKey = (manager as any).deriveEncryptionKey.bind(manager);
(manager as any).encryptionKey = await deriveEncryptionKey(password, masterSalt);
// Verify password without verification payload
const verifyPassword = (manager as any).verifyPassword.bind(manager);
const result = await verifyPassword();
expect(result).toBe(false);
// Key should be cleared when verification payload is missing
expect(manager.isUnlocked).toBe(false);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts` around lines 478 -
492, Update the test so it also asserts that the manager's unlocked state and
in-memory key are cleared when verification payload is missing: after calling
the bound verifyPassword() and asserting the returned result is false, add
assertions that manager.isUnlocked is false and that (manager as
any).encryptionKey has been cleared (null/undefined) to match the updated
implementation of verifyPassword/deriveEncryptionKey behavior.

Comment on lines +1345 to +1375
it('should handle existing encrypted data after migration', async () => {
const password = 'test-password';
const oldAccountData = { privateKey: '0xOLD_KEY' };
const newAccountData = { privateKey: '0xNEW_KEY' };

// Simulate old implementation
const encoder = new TextEncoder();
const oldBaseKey = await globalThis.crypto.subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
(manager as any).encryptionKey = oldBaseKey;
await manager.saveAccount(oldAccountData);

// Lock and use new implementation
manager.lock();
const newManager = new SecureStorageManager(storage);

// First unlock with new implementation (creates master salt)
await newManager.unlock(password);

// Save new data with new implementation
await newManager.saveAccount(newAccountData);

// Verify we can retrieve the new data
const retrieved = await newManager.getAccount();
expect(retrieved).toEqual(newAccountData);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Test doesn't verify old data readability after migration.

Similar to the previous test, this test saves new data after migration but never calls getAccount() to verify the old encrypted data ({ privateKey: '0xOLD_KEY' }) is readable. Since the key derivation changed, getAccount() would fail with "Invalid password or corrupted data" when trying to read the old data.

The test verifies new encryption works, not backward compatibility with existing encrypted data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts` around lines 1345 -
1375, The test saves old encrypted data using the old key derivation but never
verifies it can still be read after migration; update the test to call
getAccount() against the newManager after migration and assert it returns the
original oldAccountData (e.g., call newManager.getAccount() and expect toEqual
oldAccountData) before/alongside verifying the newly saved newAccountData,
ensuring SecureStorageManager.unlock(password),
manager.saveAccount(oldAccountData), and newManager.getAccount() are exercised
for backward-compatibility.

Comment on lines +165 to 179
private async verifyPassword(): Promise<boolean> {
const payload = await this.storage.get('verification_payload') as EncryptedPayload | null;
if (!payload) {
return false;
}

try {
await this.decryptData(payload);
return true;
} catch {
// Decryption failed - wrong password
this.encryptionKey = null;
return false;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Inconsistent state when verification_payload is missing.

When verification_payload is null (lines 167-169), the method returns false but does not clear encryptionKey. This creates an inconsistent state where unlock() returns false while isUnlocked remains true.

This edge case can occur if:

  • master_salt exists but verification_payload was deleted or corrupted
  • Storage experienced partial corruption

After unlock() returns false, callers expect the manager to be locked, but it remains unlocked with a potentially incorrect key.

🔒 Proposed fix to clear key when payload is missing
   private async verifyPassword(): Promise<boolean> {
     const payload = await this.storage.get('verification_payload') as EncryptedPayload | null;
     if (!payload) {
+      this.encryptionKey = null;
       return false;
     }
 
     try {
       await this.decryptData(payload);
       return true;
     } catch {
       // Decryption failed - wrong password
       this.encryptionKey = null;
       return false;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 165 -
179, The method verifyPassword can return false when
storage.get('verification_payload') yields null but leaves this.encryptionKey
set, causing unlock()/isUnlocked to be inconsistent; update verifyPassword (the
block that handles a missing verification_payload) to clear this.encryptionKey
(set it to null) before returning false so the manager is in a locked state,
ensuring consistency with decryptData error handling and the expectations of
unlock()/isUnlocked.

Comment on lines +11 to +12
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find all StorageAdapter implementations and usages to verify type compatibility

# Find StorageAdapter implementations
echo "=== StorageAdapter implementations ==="
rg -n "implements StorageAdapter" --type=ts

# Find usages of storage.get and storage.set
echo -e "\n=== storage.get usages ==="
rg -n "\.get\s*\(" --type=ts -g '!**/node_modules/**' -g '!**/*.test.ts' packages/core-sdk/src/

echo -e "\n=== storage.set usages ==="
rg -n "\.set\s*\(" --type=ts -g '!**/node_modules/**' -g '!**/*.test.ts' packages/core-sdk/src/

Repository: ancore-org/ancore

Length of output: 1397


🏁 Script executed:

# Check MockStorageAdapter implementations in test files
cat -n packages/core-sdk/src/storage/__tests__/unlock.test.ts | head -40

Repository: ancore-org/ancore

Length of output: 1399


🏁 Script executed:

# Check the get('master_salt') usage and surrounding context
sed -n '95,105p' packages/core-sdk/src/storage/secure-storage-manager.ts

Repository: ancore-org/ancore

Length of output: 484


🏁 Script executed:

# Check lines around 166, 242, 254 to verify all get() usages have proper type handling
sed -n '163,170p' packages/core-sdk/src/storage/secure-storage-manager.ts
sed -n '239,258p' packages/core-sdk/src/storage/secure-storage-manager.ts

Repository: ancore-org/ancore

Length of output: 1058


Update MockStorageAdapter test implementations to use unknown instead of any.

The StorageAdapter interface now uses unknown in get() and set() method signatures. While the primary implementation in secure-storage-manager.ts correctly handles this with type narrowing (line 99) and type assertions (lines 166, 242, 254), the MockStorageAdapter implementations in test files (unlock.test.ts and manager.test.ts) still use any for their method signatures. Update both test mocks to use unknown to maintain consistency with the interface contract:

async get(key: string): Promise<unknown>
async set(key: string, value: unknown): Promise<void>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/types.ts` around lines 11 - 12, Update the
MockStorageAdapter implementations used in the tests so their method signatures
match the StorageAdapter interface: change async get(key: string): Promise<any>
to async get(key: string): Promise<unknown> and change async set(key: string,
value: any): Promise<void> to async set(key: string, value: unknown):
Promise<void>; specifically edit the MockStorageAdapter declarations referenced
in unlock.test and manager.test and adjust any test code that assumed `any` (add
type narrowing or assertions where the mock return values are consumed) so the
tests compile against the new unknown types.

- Implement SecureStorageManager.unlock(password) that returns boolean
- Add master salt management (16-byte persistent salt)
- Implement PBKDF2 key derivation (100k iterations, SHA-256)
- Add password verification using verification payload
- Encryption key stored only in memory, never persisted
- Returns true for valid passwords, false for invalid passwords
- Add comprehensive security tests (108 tests total, 100% coverage)
- Maintain backward compatibility with existing encrypted data
- Fix ESLint configuration for Jest globals and Web Crypto API types
- Replace 'any' types with 'unknown' for better type safety

Closes ancore-org#116
@winnpxl winnpxl force-pushed the feat/secure-storage-unlock-116 branch from d7b4944 to 845af82 Compare March 24, 2026 10:52
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/core-sdk/src/storage/secure-storage-manager.ts (1)

41-69: ⚠️ Potential issue | 🔴 Critical

Serialize unlock() and keep the candidate key local.

The missing-payload branch at Line 167-Line 168 already shows one stale-state path—unlock() can return false while the derived key from Line 55/Line 63 is still published—and the same shared-state design lets concurrent unlock() calls race on this.encryptionKey. In practice, a wrong-password call can return true if a correct unlock is in flight. Keep a local candidateKey through verification/bootstrap and gate the whole method behind a single in-flight promise or mutex.

🔐 Suggested shape
+ private unlockPromise: Promise<boolean> | null = null;
+
  public async unlock(password: string): Promise<boolean> {
-   // Already unlocked - return true immediately
    if (this.encryptionKey) {
      return true;
    }
+   if (this.unlockPromise) {
+     return this.unlockPromise;
+   }
+
+   this.unlockPromise = this.doUnlock(password);
+   try {
+     return await this.unlockPromise;
+   } finally {
+     this.unlockPromise = null;
+   }
+ }
+
+ private async doUnlock(password: string): Promise<boolean> {
+   let masterSalt = await this.loadMasterSalt();

-   // Check if master salt exists (first-run vs subsequent-run)
-   let masterSalt = await this.loadMasterSalt();
-   
    if (!masterSalt) {
-     // First run: generate and store master salt
      masterSalt = await this.initializeMasterSalt();
-     this.encryptionKey = await this.deriveEncryptionKey(password, masterSalt);
-     await this.createVerificationPayload();
+     const candidateKey = await this.deriveEncryptionKey(password, masterSalt);
+     await this.createVerificationPayload(candidateKey);
+     this.encryptionKey = candidateKey;
      return true;
-   } else {
-     this.encryptionKey = await this.deriveEncryptionKey(password, masterSalt);
-     const isValid = await this.verifyPassword();
-     return isValid;
    }
+
+   const candidateKey = await this.deriveEncryptionKey(password, masterSalt);
+   const isValid = await this.verifyPassword(candidateKey);
+   if (isValid) {
+     this.encryptionKey = candidateKey;
+   }
+   return isValid;
  }

Pass candidateKey through createVerificationPayload(), verifyPassword(), encryptData(), decryptData(), and deriveAesKey() instead of reading shared mutable state during the async flow.

Also applies to: 165-169

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 41 -
69, unlock() publishes this.encryptionKey too early and allows races:
deriveEncryptionKey() sets shared this.encryptionKey before verification, so
concurrent unlock() calls or failing verification can leave a stale/incorrect
key; fix by keeping a local candidateKey (from deriveEncryptionKey()) and do not
assign to this.encryptionKey until verification/bootstrap succeeds, pass
candidateKey into createVerificationPayload(), verifyPassword(), encryptData(),
decryptData(), and deriveAesKey() so they use the local key during async flows,
and serialize unlock() with a mutex or single in-flight promise to prevent
concurrent unlocks from racing.
🧹 Nitpick comments (1)
packages/core-sdk/src/storage/types.ts (1)

11-12: Call out this exported type narrowing in the release plan.

StorageAdapter, AccountData, and SessionKeysData are part of the public SDK surface (packages/core-sdk/src/index.ts, Lines 35-37). Tightening these loose fields from any to unknown is a good safety improvement, but it is also a compile-time breaking change for downstream TypeScript consumers and adapter implementations. Please make sure the changelog / semver plan treats this as public API surface.

Also applies to: 16-23

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/types.ts` around lines 11 - 12, This change
tightens public SDK types (StorageAdapter, AccountData, SessionKeysData) from
any to unknown which is a compile-time breaking change for downstream TypeScript
consumers and adapter implementations; update the release plan/changelog and
semver policy to explicitly call out that these exported types in
packages/core-sdk (e.g., StorageAdapter, AccountData, SessionKeysData defined in
src/storage/types.ts) were narrowed to unknown and therefore require adapter
updates or type adjustments, and indicate whether this will be released as a
major/minor bump and provide migration guidance for implementers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 50-58: The current first-run flow persists master_salt before
creating the verification_payload, which can leave the store bricked if
createVerificationPayload() or its storage.set() fails; change
SecureStorageManager initialization so initializeMasterSalt() only returns an
in-memory salt (do not write it yet), then call deriveEncryptionKey(password,
masterSalt) and createVerificationPayload() (and persist the verification
payload), and only after those succeed persist the master_salt (or if
persistence of master_salt fails, roll back the verification_payload); apply the
same ordering/rollback change for the second occurrence around lines 87-91
(where master_salt and verification_payload are handled).
- Around line 98-104: The loadMasterSalt method currently treats any falsy or
non-string master_salt as "not initialized" which can silently rotate keys;
change loadMasterSalt to only treat undefined/null as uninitialized, otherwise
validate the stored value: ensure storage.get('master_salt') is a string,
attempt to decode with base64ToBuffer, verify the resulting buffer length is
exactly 16 bytes, and if decoding fails or length !== 16 throw an error (do not
return null) so corrupted master_salt is treated as fatal rather than a
first-run case.

---

Duplicate comments:
In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 41-69: unlock() publishes this.encryptionKey too early and allows
races: deriveEncryptionKey() sets shared this.encryptionKey before verification,
so concurrent unlock() calls or failing verification can leave a stale/incorrect
key; fix by keeping a local candidateKey (from deriveEncryptionKey()) and do not
assign to this.encryptionKey until verification/bootstrap succeeds, pass
candidateKey into createVerificationPayload(), verifyPassword(), encryptData(),
decryptData(), and deriveAesKey() so they use the local key during async flows,
and serialize unlock() with a mutex or single in-flight promise to prevent
concurrent unlocks from racing.

---

Nitpick comments:
In `@packages/core-sdk/src/storage/types.ts`:
- Around line 11-12: This change tightens public SDK types (StorageAdapter,
AccountData, SessionKeysData) from any to unknown which is a compile-time
breaking change for downstream TypeScript consumers and adapter implementations;
update the release plan/changelog and semver policy to explicitly call out that
these exported types in packages/core-sdk (e.g., StorageAdapter, AccountData,
SessionKeysData defined in src/storage/types.ts) were narrowed to unknown and
therefore require adapter updates or type adjustments, and indicate whether this
will be released as a major/minor bump and provide migration guidance for
implementers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24574e41-7a51-435b-a44c-7184472f23d6

📥 Commits

Reviewing files that changed from the base of the PR and between d7b4944 and 845af82.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • .gitignore
  • packages/core-sdk/eslint.config.cjs
  • packages/core-sdk/src/storage/__tests__/manager.test.ts
  • packages/core-sdk/src/storage/__tests__/unlock.test.ts
  • packages/core-sdk/src/storage/secure-storage-manager.ts
  • packages/core-sdk/src/storage/types.ts
  • packages/core-sdk/tsconfig.json
✅ Files skipped from review due to trivial changes (2)
  • .gitignore
  • packages/core-sdk/tsconfig.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core-sdk/src/storage/tests/manager.test.ts
  • packages/core-sdk/eslint.config.cjs
  • packages/core-sdk/src/storage/tests/unlock.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/core-sdk/src/storage/__tests__/unlock.test.ts (1)

467-481: ⚠️ Potential issue | 🟡 Minor

Test should verify isUnlocked state when verification payload is missing.

This test verifies that verifyPassword() returns false when the payload doesn't exist, but doesn't verify that the encryption key is cleared. Once the implementation bug in verifyPassword() is fixed (to clear encryptionKey when payload is missing), add an assertion to verify the locked state.

💚 Proposed test enhancement after implementation fix
     it('should return false when verification payload does not exist', async () => {
       // Set up encryption key directly without calling unlock (which would create the verification payload)
       const password = 'test-password';
       const masterSalt = new Uint8Array(16);
       globalThis.crypto.getRandomValues(masterSalt);
       
       const deriveEncryptionKey = (manager as any).deriveEncryptionKey.bind(manager);
       (manager as any).encryptionKey = await deriveEncryptionKey(password, masterSalt);
       
       // Verify password without verification payload
       const verifyPassword = (manager as any).verifyPassword.bind(manager);
       const result = await verifyPassword();
       
       expect(result).toBe(false);
+      // Key should be cleared when verification payload is missing
+      expect(manager.isUnlocked).toBe(false);
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts` around lines 467 -
481, The test currently checks that verifyPassword() returns false when the
verification payload is missing but doesn't assert that the manager's unlocked
state is cleared; update the test to assert that (manager as any).encryptionKey
is cleared (null/undefined) and that manager.isUnlocked() (or the public
isUnlocked property/method on the manager) returns false after calling
verifyPassword(), and ensure this aligns with the fix in verifyPassword() which
must clear encryptionKey when payload is missing.
packages/core-sdk/src/storage/secure-storage-manager.ts (1)

171-185: ⚠️ Potential issue | 🟠 Major

Inconsistent state when verification_payload is missing.

When payload is null (line 173-174), the method returns false but does not clear encryptionKey. This creates an inconsistent state where unlock() returns false while isUnlocked remains true. The catch block correctly clears encryptionKey on decryption failure, but this early return does not.

This edge case can occur if master_salt exists but verification_payload was deleted or corrupted.

🔒 Proposed fix to clear key when payload is missing
   private async verifyPassword(): Promise<boolean> {
     const payload = await this.storage.get('verification_payload') as EncryptedPayload | null;
     if (!payload) {
+      this.encryptionKey = null;
       return false;
     }
 
     try {
       await this.decryptData(payload);
       return true;
     } catch {
       // Decryption failed - wrong password
       this.encryptionKey = null;
       return false;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 171 -
185, In verifyPassword: the early return when the 'verification_payload' is
missing leaves this.encryptionKey set, causing isUnlocked to be inconsistent;
update the branch that handles payload === null to clear this.encryptionKey (set
this.encryptionKey = null) before returning false so the manager's unlocked
state matches the failure, keeping the existing catch behavior intact.
🧹 Nitpick comments (4)
packages/core-sdk/src/storage/secure-storage-manager.ts (2)

50-61: Inconsistent indentation in the first-run code path.

The code block inside the if (!masterSalt) branch has inconsistent indentation. Lines 51-60 should be indented relative to the if statement, and return true on line 61 should align with the rest of the block body.

🧹 Proposed fix to correct indentation
     if (!masterSalt) {
-    // First run: generate salt in memory only
-    masterSalt = this.initializeMasterSalt();
-
-    // Derive encryption key from password and master salt
-    this.encryptionKey = await this.deriveEncryptionKey(password, masterSalt);
-
-    // Store verification payload first, then persist salt last (atomic ordering)
-    await this.createVerificationPayload();
-    await this.storage.set('master_salt', bufferToBase64(masterSalt));
-
-  return true;
+      // First run: generate salt in memory only
+      masterSalt = this.initializeMasterSalt();
+
+      // Derive encryption key from password and master salt
+      this.encryptionKey = await this.deriveEncryptionKey(password, masterSalt);
+
+      // Store verification payload first, then persist salt last (atomic ordering)
+      await this.createVerificationPayload();
+      await this.storage.set('master_salt', bufferToBase64(masterSalt));
+
+      return true;
     } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 50 -
61, Fix the inconsistent indentation inside the first-run branch checking `if
(!masterSalt)`: indent the entire block that calls
`this.initializeMasterSalt()`, `this.deriveEncryptionKey(password, masterSalt)`,
`this.createVerificationPayload()`, and `this.storage.set('master_salt',
bufferToBase64(masterSalt))` so all statements are aligned as the body of the
`if`, and ensure the `return true` statement is inside that same indented block
(aligned with the other statements) so the control flow is correct and visually
consistent.

84-90: Outdated docstring for initializeMasterSalt.

The docstring states "Generates a random 16-byte master salt and stores it in the storage adapter" but the method now only generates and returns the salt in memory. Storage is handled by unlock().

📝 Proposed fix for docstring
   /**
-   * Generates a random 16-byte master salt and stores it in the storage adapter.
-   * `@returns` The generated master salt as a Uint8Array
+   * Generates a random 16-byte master salt in memory.
+   * Note: The caller is responsible for persisting the salt to storage.
+   * `@returns` The generated master salt as a Uint8Array
    */
   private initializeMasterSalt(): Uint8Array {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 84 -
90, Update the docstring for initializeMasterSalt to accurately describe its
behavior: state that it generates and returns a random 16-byte master salt as a
Uint8Array but does not persist it (storage is handled by unlock()), remove the
phrase "and stores it in the storage adapter", and ensure the return description
matches the Uint8Array result.
packages/core-sdk/src/storage/__tests__/unlock.test.ts (2)

1295-1332: Test name is misleading - it doesn't verify decryption of old data.

The test is named "should decrypt data encrypted with old unlock implementation" but:

  1. It encrypts data with simulated old key derivation
  2. Creates a new manager and calls unlock() which initializes new master_salt
  3. Only verifies master_salt and verification_payload are created

The inline comments acknowledge this: "Note: This will fail because new implementation requires master_salt". This is actually testing first-run initialization, not backward compatibility.

Consider renaming for clarity, or add a comment to the describe block noting this is a breaking change requiring migration.

📝 Suggested rename for clarity
-    it('should decrypt data encrypted with old unlock implementation', async () => {
+    it('should initialize new master_salt when unlocking after legacy encryption (note: old data is not readable)', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts` around lines 1295 -
1332, Rename or update the test that currently uses manager.saveAccount,
manager.lock, new SecureStorageManager(storage) and newManager.unlock(password)
so its name and assertions reflect that it is testing first-run initialization
(creation of master_salt and verification_payload) rather than decryption of
legacy data; specifically either rename the test string from "should decrypt
data encrypted with old unlock implementation" to something like "should
initialize master_salt and verification_payload on first run when legacy key
present" or change the assertions to actually attempt decryption of the payload
saved by the simulated old implementation (derive the correct decryption key
from (manager as any).encryptionKey and verify saved account data), and update
comments to clarify whether this is a breaking change requiring migration.

1334-1364: Test doesn't verify old encrypted data is readable after migration.

This test saves data with simulated old key derivation, then migrates and saves NEW data. It only verifies the new data can be retrieved. The old encrypted data ({ privateKey: '0xOLD_KEY' }) is overwritten and never tested for readability.

Since the key derivation changed, old data would NOT be decryptable - this is expected but should be documented clearly in the test.

📝 Suggested clarification
     it('should handle existing encrypted data after migration', async () => {
       const password = 'test-password';
       const oldAccountData = { privateKey: '0xOLD_KEY' };
       const newAccountData = { privateKey: '0xNEW_KEY' };
       
       // Simulate old implementation
       const encoder = new TextEncoder();
       const oldBaseKey = await globalThis.crypto.subtle.importKey(
         'raw',
         encoder.encode(password),
         { name: 'PBKDF2' },
         false,
         ['deriveKey']
       );
       (manager as any).encryptionKey = oldBaseKey;
       await manager.saveAccount(oldAccountData);
       
       // Lock and use new implementation
       manager.lock();
       const newManager = new SecureStorageManager(storage);
       
-      // First unlock with new implementation (creates master salt)
+      // First unlock with new implementation (creates master salt)
+      // Note: Old encrypted data is NOT readable with new key derivation
       await newManager.unlock(password);
       
-      // Save new data with new implementation
+      // Save new data (overwrites old encrypted data)
       await newManager.saveAccount(newAccountData);
       
-      // Verify we can retrieve the new data
+      // Verify we can retrieve the newly saved data (not the old data)
       const retrieved = await newManager.getAccount();
       expect(retrieved).toEqual(newAccountData);
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts` around lines 1334 -
1364, The test currently overwrites old encrypted data and only asserts the new
data is readable; update the test to explicitly check old-data behavior after
migration: after creating old encrypted data with manager.saveAccount and then
creating newManager and calling newManager.unlock(password), attempt to retrieve
the old data (e.g., call newManager.getAccount or a dedicated read method before
saving newAccountData) and assert the expected behavior—either that the old data
is not decryptable (expect null/throws) or, if intended, that it is migrated and
equals oldAccountData; include a short comment clarifying which behavior is
expected and why (key derivation change vs. migration), and reference
manager.saveAccount, manager.lock, SecureStorageManager constructor,
newManager.unlock, and newManager.getAccount so you modify the correct steps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts`:
- Around line 126-133: The test in unlock.test.ts conflicts with
loadMasterSalt(): it expects null for an empty string but loadMasterSalt()
currently decodes the empty string to a 0-byte buffer and throws "Corrupted
master_salt: expected 16 bytes"; fix by choosing one of two options: (A) Update
the test to expect an error from (manager as any).loadMasterSalt() when storage
contains '' (i.e., assert that it throws the existing error), or (B) change the
implementation of loadMasterSalt() to treat an empty string as uninitialized by
adding an early check (e.g., treat base64Salt === '' as null / return null
before calling base64ToBuffer) so the test remains valid; reference the
loadMasterSalt function and base64ToBuffer usage to locate the fix.

---

Duplicate comments:
In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts`:
- Around line 467-481: The test currently checks that verifyPassword() returns
false when the verification payload is missing but doesn't assert that the
manager's unlocked state is cleared; update the test to assert that (manager as
any).encryptionKey is cleared (null/undefined) and that manager.isUnlocked() (or
the public isUnlocked property/method on the manager) returns false after
calling verifyPassword(), and ensure this aligns with the fix in
verifyPassword() which must clear encryptionKey when payload is missing.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 171-185: In verifyPassword: the early return when the
'verification_payload' is missing leaves this.encryptionKey set, causing
isUnlocked to be inconsistent; update the branch that handles payload === null
to clear this.encryptionKey (set this.encryptionKey = null) before returning
false so the manager's unlocked state matches the failure, keeping the existing
catch behavior intact.

---

Nitpick comments:
In `@packages/core-sdk/src/storage/__tests__/unlock.test.ts`:
- Around line 1295-1332: Rename or update the test that currently uses
manager.saveAccount, manager.lock, new SecureStorageManager(storage) and
newManager.unlock(password) so its name and assertions reflect that it is
testing first-run initialization (creation of master_salt and
verification_payload) rather than decryption of legacy data; specifically either
rename the test string from "should decrypt data encrypted with old unlock
implementation" to something like "should initialize master_salt and
verification_payload on first run when legacy key present" or change the
assertions to actually attempt decryption of the payload saved by the simulated
old implementation (derive the correct decryption key from (manager as
any).encryptionKey and verify saved account data), and update comments to
clarify whether this is a breaking change requiring migration.
- Around line 1334-1364: The test currently overwrites old encrypted data and
only asserts the new data is readable; update the test to explicitly check
old-data behavior after migration: after creating old encrypted data with
manager.saveAccount and then creating newManager and calling
newManager.unlock(password), attempt to retrieve the old data (e.g., call
newManager.getAccount or a dedicated read method before saving newAccountData)
and assert the expected behavior—either that the old data is not decryptable
(expect null/throws) or, if intended, that it is migrated and equals
oldAccountData; include a short comment clarifying which behavior is expected
and why (key derivation change vs. migration), and reference
manager.saveAccount, manager.lock, SecureStorageManager constructor,
newManager.unlock, and newManager.getAccount so you modify the correct steps.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 50-61: Fix the inconsistent indentation inside the first-run
branch checking `if (!masterSalt)`: indent the entire block that calls
`this.initializeMasterSalt()`, `this.deriveEncryptionKey(password, masterSalt)`,
`this.createVerificationPayload()`, and `this.storage.set('master_salt',
bufferToBase64(masterSalt))` so all statements are aligned as the body of the
`if`, and ensure the `return true` statement is inside that same indented block
(aligned with the other statements) so the control flow is correct and visually
consistent.
- Around line 84-90: Update the docstring for initializeMasterSalt to accurately
describe its behavior: state that it generates and returns a random 16-byte
master salt as a Uint8Array but does not persist it (storage is handled by
unlock()), remove the phrase "and stores it in the storage adapter", and ensure
the return description matches the Uint8Array result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e0acd4cc-508d-473e-8852-93cb41d811a6

📥 Commits

Reviewing files that changed from the base of the PR and between 845af82 and 35aa067.

📒 Files selected for processing (2)
  • packages/core-sdk/src/storage/__tests__/unlock.test.ts
  • packages/core-sdk/src/storage/secure-storage-manager.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/core-sdk/src/storage/secure-storage-manager.ts (1)

171-183: ⚠️ Potential issue | 🟠 Major

Re-lock on every failed verification path.

verifyPassword() only clears this.encryptionKey inside the decrypt catch. The missing-payload branch on Line 173, and any rejection before Line 177, leave the stale derived key live even though unlock() failed. A retry then hits the early return on Line 43.

🔒 Minimal fix
   private async verifyPassword(): Promise<boolean> {
-    const payload = await this.storage.get('verification_payload') as EncryptedPayload | null;
-    if (!payload) {
-      return false;
-    }
-
     try {
+      const payload = await this.storage.get('verification_payload') as EncryptedPayload | null;
+      if (!payload) {
+        this.encryptionKey = null;
+        return false;
+      }
       await this.decryptData(payload);
       return true;
     } catch {
       // Decryption failed - wrong password
       this.encryptionKey = null;
       return false;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 171 -
183, verifyPassword currently only clears this.encryptionKey inside the
decryptData catch, leaving a stale key when storage.get returns null or if
storage.get throws; update verifyPassword so any failure path clears
this.encryptionKey and returns false: wrap the payload retrieval and decryptData
call in a try/catch, set this.encryptionKey = null before returning false when
payload is missing, and ensure the catch handler clears this.encryptionKey and
returns false; reference verifyPassword, decryptData and the unlock behavior
that relies on encryptionKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 50-61: The unlock() first-run path sets this.encryptionKey after
calling initializeMasterSalt() and deriveEncryptionKey() but before persisting
verification data and master salt, so if createVerificationPayload() or
storage.set('master_salt', ...) throws the in-memory encryptionKey remains set
and future unlock() calls will short-circuit; to fix, defer assigning
this.encryptionKey (and/or masterSalt) until after createVerificationPayload()
and storage.set(...) succeed, or wrap the sequence in a try/catch that clears
this.encryptionKey and masterSalt on any failure (referencing unlock,
initializeMasterSalt, deriveEncryptionKey, createVerificationPayload,
storage.set, this.encryptionKey, masterSalt).
- Around line 204-208: Remove the duplicate declarations of salt and iv: there
are two consecutive calls creating salt and iv via
globalThis.crypto.getRandomValues (one pair without casting and one pair with
"as any"); delete the redundant lines that re-declare const salt and const iv
(the ones using "as any") so only a single const salt and const iv remain in the
scope where they're used (e.g., in the secure storage initialization function),
ensuring no duplicate declarations/syntax error.

---

Duplicate comments:
In `@packages/core-sdk/src/storage/secure-storage-manager.ts`:
- Around line 171-183: verifyPassword currently only clears this.encryptionKey
inside the decryptData catch, leaving a stale key when storage.get returns null
or if storage.get throws; update verifyPassword so any failure path clears
this.encryptionKey and returns false: wrap the payload retrieval and decryptData
call in a try/catch, set this.encryptionKey = null before returning false when
payload is missing, and ensure the catch handler clears this.encryptionKey and
returns false; reference verifyPassword, decryptData and the unlock behavior
that relies on encryptionKey.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ee9b343e-7051-4b8b-8266-f8e411f7e7e1

📥 Commits

Reviewing files that changed from the base of the PR and between 600e02d and a9255b2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • packages/core-sdk/src/storage/__tests__/manager.test.ts
  • packages/core-sdk/src/storage/secure-storage-manager.ts

Comment on lines +50 to +61
if (!masterSalt) {
// First run: generate salt in memory only
masterSalt = this.initializeMasterSalt();

// Derive encryption key from password and master salt
this.encryptionKey = await this.deriveEncryptionKey(password, masterSalt);

// Store verification payload first, then persist salt last (atomic ordering)
await this.createVerificationPayload();
await this.storage.set('master_salt', bufferToBase64(masterSalt));

return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clear or defer encryptionKey if first-run setup fails.

If createVerificationPayload() or storage.set('master_salt') rejects, unlock() fails after Line 55 has already published this.encryptionKey. The next call will short-circuit on Line 43 and can save data with a salt that was never persisted.

🧱 Safer failure handling
     masterSalt = this.initializeMasterSalt();

     // Derive encryption key from password and master salt
     this.encryptionKey = await this.deriveEncryptionKey(password, masterSalt);

     // Store verification payload first, then persist salt last (atomic ordering)
-    await this.createVerificationPayload();
-    await this.storage.set('master_salt', bufferToBase64(masterSalt));
-
-  return true;
+    try {
+      await this.createVerificationPayload();
+      await this.storage.set('master_salt', bufferToBase64(masterSalt));
+      return true;
+    } catch (error) {
+      this.encryptionKey = null;
+      await this.storage.remove('verification_payload');
+      throw error;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core-sdk/src/storage/secure-storage-manager.ts` around lines 50 -
61, The unlock() first-run path sets this.encryptionKey after calling
initializeMasterSalt() and deriveEncryptionKey() but before persisting
verification data and master salt, so if createVerificationPayload() or
storage.set('master_salt', ...) throws the in-memory encryptionKey remains set
and future unlock() calls will short-circuit; to fix, defer assigning
this.encryptionKey (and/or masterSalt) until after createVerificationPayload()
and storage.set(...) succeed, or wrap the sequence in a try/catch that clears
this.encryptionKey and masterSalt on any failure (referencing unlock,
initializeMasterSalt, deriveEncryptionKey, createVerificationPayload,
storage.set, this.encryptionKey, masterSalt).

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.

Secure Storage Unlock (derive key from password)

2 participants