Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan.
- Step 26 (Auto-Save + Sync Flow) — complete
- Step 27 (Supabase Realtime Adapter) — complete
- Step 28 (Multi-Device Session Handling) — complete
- Step 29 (Change Password Flow + UI) — complete

### Implementation Notes

Expand Down Expand Up @@ -212,5 +213,6 @@ Non-obvious decisions not visible from code alone:
- **Auth error codes fold username format into invalid credentials**: `AuthErrorCode.INVALID_USERNAME_FORMAT` doesn't exist — `supabase-keys.ts` throws `INVALID_CREDENTIALS` for invalid username format. This is deliberate: showing a different error for "wrong format" vs "wrong password" would leak whether a username exists. Missing key data is `ApiError(NOT_FOUND)`, not an auth error — `KEYS_NOT_FOUND` was removed from `AuthErrorCode` because "data not found" is a data-layer concern, not an auth concern.
- **`AuthError` vs `ApiError` domain boundary**: `AuthError` (in `shared/auth/auth-errors.ts`) covers authentication errors (`INVALID_CREDENTIALS`, `USERNAME_TAKEN`, `NETWORK_ERROR`, `UNEXPECTED`). `ApiError` (in `shared/api/api-errors.ts`) covers data-layer errors (`NETWORK_ERROR`, `NOT_FOUND`, `UNEXPECTED`). `fetchLoginSalts` throws `AuthError` because it's a pre-auth RPC that's part of the login flow; all other data queries throw `ApiError`. Each domain has its own `wrapXxxError` that classifies raw errors using the shared `isNetworkError` helper.
- **Network errors can bypass the adapter boundary**: `isNetworkError` (in `shared/lib/network-errors.ts`) is shared by both `wrapAuthError` and `wrapApiError`. Raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by any adapter, so `getAuthErrorMessage` in `auth-error-messages.ts` also calls `isNetworkError` as a final fallback.
- **Change password rollback**: `changeUserPassword` in `auth-service.ts` is a 4-step flow (derive → upload envelope → update auth → update store). If step 3 (Supabase Auth update) fails after step 2 (DB envelope upload) succeeds, it attempts to roll back the DB write with the old envelope values. If rollback also fails, it forces logout to prevent inconsistent state.
- **Stale KEK detection**: Stale KEK from a password change on another device is detected in two places: (1) `use-realtime-sync.ts` `onKeyRotation` — when a key rotation event arrives, `syncFieldKeys` tries to unwrap with the current KEK; a `DecryptionError` means the KEK is stale, so `clearVault()` forces re-auth. (2) `key-vault.ts` `unlockVault` — if the cached envelope is stale, `clearVault` + retry from server. The save path (`useSaveField`) cannot produce a `DecryptionError` — `encryptField` only encrypts, and `getFieldKey` throws a generic `Error` when the vault is locked, not `DecryptionError`.

37 changes: 20 additions & 17 deletions docs/implementation-plan/08-phase-8-recovery.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
# Phase 8: Password & Recovery

## Step 29 — Change Password Flow + UI
## Step 29 — Change Password Flow + UI

**Goal:** Change password without re-encrypting field data (only re-wrap master key).

**Code:**
- `src/shared/crypto/change-password.ts`:
- Pure crypto function: derive new split KDF, unwrap master key with old password key, re-wrap with new password key
- No side effects (same pattern as `registration-crypto.ts`)
- `src/features/settings/ui/ChangePasswordDialog.tsx`:
- Current password input + new password input + confirm new password
- Validation: new password must differ from current, confirm matches
- On submit:
1. Call change-password crypto function
2. Upload new wrapped master key + new salts to server
- Validate `changePassword` pure crypto function in split-kdf module:
- Derive old password key from existing `keySalt`, unwrap master key
- Generate fresh `authSalt` and `keySalt`, derive new credentials via `deriveAuthCredentials`
- Re-wrap master key with new password key, return new auth hash + salts + wrapped master key + IV
- Zero out sensitive intermediate values
- `src/features/auth/ui/ChangePasswordDialog.tsx`:
- Current password input + new password input (with password strength) + confirm new password
- Zod schema validation: required fields, min length, new password must differ from current, passwords must match
- On submit — 4-step orchestration:
1. Call pure crypto function to derive new credentials
2. Upload new key envelope to server
3. Update Supabase Auth password (new auth_hash)
4. Update crypto store with new keys
- Show success/error feedback
- Add i18n strings to `settings.json` for password change
4. Update cached envelope in crypto store
- Rollback: if step 3 fails after step 2 succeeds, attempt DB rollback with old envelope values; if rollback also fails, force logout
- Error mapping: `DecryptionError` → incorrect password, `AuthErrorCode` / `ApiErrorCode` → appropriate messages, network error fallback
- Show toast notifications for success/error feedback
- `change-password-dialog-store.ts` — Zustand store for dialog open/close state (rendered at app layout level, triggered from SecuritySection)
- Add i18n strings to `auth.json` (not `settings.json` — this is an auth feature)

**Tests:**
- Integration: change password → logout → login with new password → verify vault unlocks
- Integration: change password → old password no longer works
- Component test: form validation works
- Security test: field data is unchanged after password change (no re-encryption)
- Integration: change password → old password can no longer unwrap master key; field keys still decrypt unchanged data
- Component test: form validation rejects mismatched/empty/too-short passwords

---

Expand Down
2 changes: 1 addition & 1 deletion docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta
- [x] Step 26 — Auto-Save + Sync Flow
- [x] Step 27 — Supabase Realtime Adapter
- [x] Step 28 — Multi-Device Session Handling
- [ ] Step 29 — Change Password Flow + UI
- [x] Step 29 — Change Password Flow + UI
- [ ] Step 30 — Seed Phrase Backup View
- [ ] Step 31 — Seed Phrase Recovery Flow + UI
- [ ] Step 32 — Key Rotation + UI
Expand Down
9 changes: 9 additions & 0 deletions src/app/layouts/ProtectedLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ vi.mock('@/features/vault/ui/VaultUnlockDialog', () => ({
VaultUnlockDialog: () => React.createElement('div', { 'data-testid': 'vault-unlock-dialog' }),
}))

vi.mock('@/features/auth/ui/ChangePasswordDialog', () => ({
ChangePasswordDialog: () => React.createElement('div', { 'data-testid': 'change-password-dialog' }),
}))

vi.mock('@/features/vault/model/vault-timeout', () => ({
useVaultTimeout: () => {},
}))
Expand Down Expand Up @@ -71,4 +75,9 @@ describe('ProtectedLayout', () => {
render(<ProtectedLayout />)
expect(screen.getByTestId('vault-unlock-dialog')).toBeInTheDocument()
})

it('renders change password dialog', () => {
render(<ProtectedLayout />)
expect(screen.getByTestId('change-password-dialog')).toBeInTheDocument()
})
})
4 changes: 3 additions & 1 deletion src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MobileNav } from './MobileNav'
import { ResizeHandle } from '@/shared/ui/nav/ResizeHandle'
import { VaultIndicator } from '@/features/vault/ui/VaultIndicator'
import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog'
import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog'
import { OfflineBanner } from '@/shared/ui/OfflineBanner'
import { useLayoutStore } from './layout-store'
import { useResizable } from '@/shared/lib/use-resizable'
Expand Down Expand Up @@ -79,8 +80,9 @@ function ProtectedLayout() {
{/* Mobile bottom navigation */}
<MobileNav />

{/* Vault unlock dialog */}
{/* Dialogs */}
<VaultUnlockDialog />
<ChangePasswordDialog />
</div>
)
}
Expand Down
185 changes: 184 additions & 1 deletion src/features/auth/model/auth-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,25 @@ vi.mock('@/shared/api/supabase-registration', () => ({
}))

// Mock Supabase keys
const { mockFetchedEnvelope } = vi.hoisted(() => ({
mockFetchedEnvelope: {
authSalt: 'f1e2d3c4'.repeat(4),
keySalt: 'b5a6g7h8'.repeat(4),
wrappedMasterKey: 'ff'.repeat(48),
masterKeyIV: 'ee'.repeat(12),
fieldKeys: [] as import('@/shared/types/api.types').ServerFieldKey[],
},
}))

vi.mock('@/shared/api/supabase-keys', () => ({
fetchLoginSalts: vi.fn().mockResolvedValue({
authSalt: '01'.repeat(16),
keySalt: '02'.repeat(16),
}),
fetchMasterKeyEnvelope: vi.fn(),
fetchFieldKeys: vi.fn(),
fetchFreshEnvelope: vi.fn().mockResolvedValue(mockFetchedEnvelope),
updateMasterKeyEnvelope: vi.fn().mockResolvedValue(undefined),
}))

// Mock Argon2id
Expand Down Expand Up @@ -95,6 +107,7 @@ vi.mock('@/shared/auth/supabase-adapter', () => ({
logout: vi.fn().mockResolvedValue(undefined),
getSession: vi.fn().mockResolvedValue(null),
onAuthStateChange: vi.fn().mockReturnValue(vi.fn()),
updatePassword: vi.fn().mockResolvedValue(undefined),
},
}))

Expand Down Expand Up @@ -135,22 +148,39 @@ vi.mock('@/shared/crypto/key-vault-service', () => ({
populateKeyVault: vi.fn().mockResolvedValue(undefined),
}))

// Mock split-kdf changePassword (hoisted mock — factory must not reference external variables)
vi.mock('@/shared/crypto/split-kdf', async () => {
const actual = await vi.importActual<typeof import('@/shared/crypto/split-kdf')>('@/shared/crypto/split-kdf')
return {
...actual,
changePassword: vi.fn().mockResolvedValue({
newAuthHash: 'newhash'.padEnd(64, '0'),
newAuthSalt: new Uint8Array(16).fill(0x11),
newKeySalt: new Uint8Array(16).fill(0x22),
newWrappedMasterKey: new Uint8Array(48).fill(0x33),
newMasterKeyIV: new Uint8Array(12).fill(0x44),
}),
}
})

import {
signUpUser,
loginUser,
logoutUser,
restoreSession,
subscribeToAuthChanges,
changeUserPassword,
} from '@/features/auth/model/auth-service'
import { deriveRegistrationKeys } from '@/features/auth/model/registration-crypto'
import { authAdapter } from '@/shared/auth/supabase-adapter'
import { uploadRegistrationData } from '@/shared/api/supabase-registration'
import { fetchLoginSalts } from '@/shared/api/supabase-keys'
import { fetchLoginSalts, updateMasterKeyEnvelope, fetchFreshEnvelope } from '@/shared/api/supabase-keys'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors'
import type { AuthResult } from '@/shared/auth/auth.types'
import { keyVault } from '@/shared/crypto/key-vault'
import { terminateWorker } from '@/shared/crypto/argon2id'
import { changePassword } from '@/shared/crypto/split-kdf'

describe('signUpUser', () => {
beforeEach(() => {
Expand Down Expand Up @@ -479,3 +509,156 @@ describe('subscribeToAuthChanges', () => {
expect(terminateWorker).toHaveBeenCalled()
})
})

describe('changeUserPassword', () => {
const mockEnvelope = {
authSalt: 'a1b2c3d4'.repeat(4),
keySalt: 'e5f6g7h8'.repeat(4),
wrappedMasterKey: 'aa'.repeat(48),
masterKeyIV: 'bb'.repeat(12),
fieldKeys: [],
}

const mockChangeResult = {
newAuthHash: 'newhash'.padEnd(64, '0'),
newAuthSalt: new Uint8Array(16).fill(0x11) as Uint8Array<ArrayBuffer>,
newKeySalt: new Uint8Array(16).fill(0x22) as Uint8Array<ArrayBuffer>,
newWrappedMasterKey: new Uint8Array(48).fill(0x33) as Uint8Array<ArrayBuffer>,
newMasterKeyIV: new Uint8Array(12).fill(0x44) as Uint8Array<ArrayBuffer>,
}

beforeEach(() => {
vi.clearAllMocks()
// Reset crypto store mock with envelope
cryptoStoreState.cachedEnvelope = mockEnvelope as unknown as import('@/shared/types/api.types').CachedVaultEnvelope
// Reset auth store mock with user
vi.mocked(useAuthStore.getState).mockReturnValue({
setLoading: mockSetLoading,
setAuth: mockSetAuth,
setRestoringSession: mockSetRestoringSession,
reset: mockReset,
isRestoringSession: false,
user: { id: 'user-1', username: 'testuser', createdAt: '2024-01-01' },
session: { accessToken: 'tok', expiresAt: 0 },
setUser: vi.fn(),
setSession: vi.fn(),
isLoading: false,
})
})

it('calls changePassword with envelope and passwords', async () => {
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)
vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined)

await changeUserPassword('oldPassword', 'newPassword')

expect(changePassword).toHaveBeenCalledWith('oldPassword', 'newPassword', mockEnvelope)
})

it('uploads new key envelope to DB', async () => {
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)
vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined)

await changeUserPassword('oldPassword', 'newPassword')

expect(updateMasterKeyEnvelope).toHaveBeenCalledWith('user-1', {
authSalt: '11111111111111111111111111111111',
keySalt: '22222222222222222222222222222222',
wrappedMasterKey: '33'.repeat(48),
masterKeyIV: '44'.repeat(12),
})
})

it('updates Supabase Auth password with new auth hash', async () => {
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)
vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined)

await changeUserPassword('oldPassword', 'newPassword')

expect(authAdapter.updatePassword).toHaveBeenCalledWith(mockChangeResult.newAuthHash)
})

it('updates cached envelope after success', async () => {
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)
vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined)

await changeUserPassword('oldPassword', 'newPassword')

expect(mockSetEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
authSalt: '11111111111111111111111111111111',
keySalt: '22222222222222222222222222222222',
wrappedMasterKey: '33'.repeat(48),
masterKeyIV: '44'.repeat(12),
}),
)
})

it('throws when no user is authenticated', async () => {
vi.mocked(useAuthStore.getState).mockReturnValue({
setLoading: mockSetLoading,
setAuth: mockSetAuth,
setRestoringSession: mockSetRestoringSession,
reset: mockReset,
isRestoringSession: false,
user: null,
session: null,
isLoading: false,
setUser: vi.fn(),
setSession: vi.fn(),
})

await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow('No authenticated user')
})

it('fetches vault envelope when cache is empty', async () => {
cryptoStoreState.cachedEnvelope = null
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)
vi.mocked(authAdapter.updatePassword).mockResolvedValueOnce(undefined)

await changeUserPassword('oldPassword', 'newPassword')

expect(fetchFreshEnvelope).toHaveBeenCalledWith('user-1')
expect(changePassword).toHaveBeenCalledWith('oldPassword', 'newPassword', mockFetchedEnvelope)
})

it('throws when fetchFreshEnvelope fails and no cache exists', async () => {
cryptoStoreState.cachedEnvelope = null
vi.mocked(fetchFreshEnvelope).mockRejectedValueOnce(new Error('Network error'))

await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow('Network error')
})

it('rolls back DB on auth update failure', async () => {
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)
vi.mocked(authAdapter.updatePassword).mockRejectedValueOnce(new AuthError(AuthErrorCode.NETWORK_ERROR))
// Rollback call
vi.mocked(updateMasterKeyEnvelope).mockResolvedValueOnce(undefined)

await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow()

// First call: upload new data; second call: rollback with old data
expect(updateMasterKeyEnvelope).toHaveBeenCalledTimes(2)
expect(updateMasterKeyEnvelope).toHaveBeenNthCalledWith(2, 'user-1', {
authSalt: mockEnvelope.authSalt,
keySalt: mockEnvelope.keySalt,
wrappedMasterKey: mockEnvelope.wrappedMasterKey,
masterKeyIV: mockEnvelope.masterKeyIV,
})
})

it('throws DB error when DB update fails', async () => {
vi.mocked(changePassword).mockResolvedValueOnce(mockChangeResult)
const dbError = new Error('DB update failed')
vi.mocked(updateMasterKeyEnvelope).mockRejectedValueOnce(dbError)

await expect(changeUserPassword('oldPassword', 'newPassword')).rejects.toThrow('DB update failed')
expect(authAdapter.updatePassword).not.toHaveBeenCalled()
})
})
Loading
Loading