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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan.
- Step 22 (Login UI + Vault Unlock) — complete
- Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete
- Step 24 (Supabase API Adapter) — complete
- Step 25 (Encrypted Field CRUD) — complete

### Implementation Notes

Expand Down
29 changes: 18 additions & 11 deletions docs/implementation-plan/06-phase-6-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,36 @@

---

## Step 25 — Encrypted Field CRUD
## Step 25 — Encrypted Field CRUD

**Goal:** Encrypt/decrypt all three field types (note, website, email) end-to-end.

**Code:**
- `src/features/fields/model/field-crypto.ts`:
- `encryptField(plaintext: string, fieldKey: Uint8Array): Promise<EncryptedFieldData>`
- `encryptField(plaintext: string, fieldKey: CryptoKey, fieldName: FieldName): Promise<EncryptedFieldData>`
- Convert string to Uint8Array (TextEncoder)
- Generate random IV
- Encrypt with AES-256-GCM using field key
- Encrypt with AES-256-GCM using non-extractable CryptoKey from KeyVault
- Bind ciphertext to field name + key version via AAD (prevents ciphertext swapping between fields)
- Return `{ ciphertext: Uint8Array, iv: Uint8Array }`
- `decryptField(encryptedData: EncryptedFieldData, fieldKey: Uint8Array): Promise<string>`
- Decrypt with AES-256-GCM
- `decryptField(encryptedData: EncryptedFieldData, fieldKey: CryptoKey, fieldName: FieldName): Promise<string>`
- Decrypt with AES-256-GCM, reconstructing AAD from fieldName + version
- Convert Uint8Array to string (TextDecoder)
- Return plaintext string
- `toSaveFieldData(encryptedData)` — convert binary `EncryptedFieldData` to hex-string `SaveFieldData` for the API
- `toEncryptedFieldData(serverField)` — convert hex-string `ServerEncryptedField` from the API to binary `EncryptedFieldData`
- `src/features/fields/model/field-service.ts`:
- `loadField(fieldName: string): Promise<string | null>` — fetch from server, decrypt, return plaintext
- `saveField(fieldName: string, plaintext: string): Promise<void>` — encrypt, save to server
- `loadAllFields(): Promise<Record<string, string | null>>` — load all three fields
- `FieldService` class (singleton `fieldService`) encapsulating auth + key vault access:
- `loadField(fieldName): Promise<string | null>` — fetch from server, decrypt, return plaintext
- `saveField(fieldName, plaintext): Promise<void>` — encrypt, save to server
- `loadAllFields(): Promise<Record<string, string | null>>` — load all three fields in parallel
- Gets user ID from auth store, gets CryptoKey from KeyVault (no separate hook needed)
- Wire into TanStack Query hooks:
- `useField(fieldName)` — query + cache decrypted field content. **Must invalidate/purge this cache when vault is locked** (see Step 23).
- `useSaveField(fieldName)` — mutation for saving field content
- `useFieldKey(fieldName)` — get field key from crypto store (hex-decode from store before use)
- `useField(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded
- `useSaveField(fieldName)` — mutation for saving field content, invalidates field query on success
- `src/shared/types/entities/field.types.ts`:
- `FieldName` type (`'note' | 'website' | 'email'`), `FIELD_NAMES` canonical list
- `EncryptedField` and `DecryptedField` interfaces for server/client representations

**Tests:**
- Unit: encrypt then decrypt returns original string
Expand Down
4 changes: 2 additions & 2 deletions src/app/layouts/PublicLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { PublicHeader } from '@/shared/ui/nav/PublicHeader'

function PublicLayout() {
return (
<div className="bg-background text-foreground relative min-h-screen overflow-hidden">
<div className="bg-background text-foreground relative flex min-h-[calc(100vh-var(--banner-height))] flex-col overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--color-primary)_0,_transparent_70%)] opacity-[0.08]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,_var(--color-primary)_0,_transparent_50%)] opacity-[0.03]" />
<PublicHeader />
<main className="container mx-auto flex min-h-screen items-center justify-center p-4">
<main className="container mx-auto flex flex-1 items-center justify-center p-4">
<Outlet />
</main>
</div>
Expand Down
Empty file removed src/features/auth/ui/.gitkeep
Empty file.
Empty file.
95 changes: 95 additions & 0 deletions src/features/fields/model/field-crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { importKey } from '@/shared/crypto/aes-gcm'
import { DecryptionError } from '@/shared/crypto/errors'
import { encryptField, decryptField, toSaveFieldData, toEncryptedFieldData } from '@/features/fields/model/field-crypto'
import { FIELD_NAMES } from '@/shared/types/entities/field.types'
import type { FieldName } from '@/shared/types/entities/field.types'

const generateKey = async () => await importKey(crypto.getRandomValues(new Uint8Array(32)))

describe('encryptField + decryptField', () => {
it('round-trips plaintext for all field names', async () => {
const key = await generateKey()
for (const fieldName of FIELD_NAMES) {
const plaintext = `Hello, ${fieldName}!`
const encrypted = await encryptField(plaintext, key, fieldName)
const decrypted = await decryptField(encrypted, key, fieldName)
expect(decrypted).toBe(plaintext)
}
})

it('round-trips an empty string', async () => {
const key = await generateKey()
const encrypted = await encryptField('', key, 'note')
const decrypted = await decryptField(encrypted, key, 'note')
expect(decrypted).toBe('')
})

it('round-trips a long string', async () => {
const key = await generateKey()
const plaintext = 'a'.repeat(10_000)
const encrypted = await encryptField(plaintext, key, 'note')
const decrypted = await decryptField(encrypted, key, 'note')
expect(decrypted).toBe(plaintext)
})

it('produces different ciphertexts for different IVs (randomness)', async () => {
const key = await generateKey()
const plaintext = 'same content'
const encrypted1 = await encryptField(plaintext, key, 'note')
const encrypted2 = await encryptField(plaintext, key, 'note')
// Random IV means ciphertext should differ each time
expect(encrypted1.ciphertext).not.toEqual(encrypted2.ciphertext)
})

it('produces different ciphertexts with different keys', async () => {
const key1 = await generateKey()
const key2 = await generateKey()
const plaintext = 'same content'
const encrypted1 = await encryptField(plaintext, key1, 'note')
const encrypted2 = await encryptField(plaintext, key2, 'note')
expect(encrypted1.ciphertext).not.toEqual(encrypted2.ciphertext)
})

it('throws DecryptionError with wrong key', async () => {
const key1 = await generateKey()
const key2 = await generateKey()
const encrypted = await encryptField('secret', key1, 'note')
await expect(decryptField(encrypted, key2, 'note')).rejects.toThrow(DecryptionError)
})

it('throws DecryptionError when field name mismatches (AAD binding)', async () => {
const key = await generateKey()
const encrypted = await encryptField('secret note', key, 'note')
// Attempt to decrypt as a different field — AAD won't match
await expect(decryptField(encrypted, key, 'website')).rejects.toThrow(DecryptionError)
})
})

describe('toSaveFieldData + toEncryptedFieldData', () => {
it('round-trips through hex encoding/decoding', async () => {
const key = await generateKey()
const plaintext = 'test data for hex round-trip'
const encrypted = await encryptField(plaintext, key, 'note')

// Internal binary → hex for API
const saveData = toSaveFieldData(encrypted)
expect(typeof saveData.encryptedBlob).toBe('string')
expect(typeof saveData.iv).toBe('string')

// Hex from API → internal binary
const serverField = {
fieldName: 'note' as FieldName,
encryptedBlob: saveData.encryptedBlob,
iv: saveData.iv,
updatedAt: '2025-01-01T00:00:00Z',
}
const restored = toEncryptedFieldData(serverField)
expect(restored.ciphertext).toEqual(encrypted.ciphertext)
expect(restored.iv).toEqual(encrypted.iv)

// Full round-trip: encrypt → toSaveFieldData → toEncryptedFieldData → decrypt
const decrypted = await decryptField(restored, key, 'note')
expect(decrypted).toBe(plaintext)
})
})
56 changes: 56 additions & 0 deletions src/features/fields/model/field-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { decrypt, encrypt } from '@/shared/crypto/aes-gcm'
import { encodeAAD, generateIV, hexDecode, hexEncode } from '@/shared/crypto/crypto-utils'
import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types'
import type { EncryptedFieldData } from '@/shared/types/crypto.types'
import type { FieldName } from '@/shared/types/entities/field.types'
import type { SaveFieldData, ServerEncryptedField } from '@/shared/types/api.types'

/**
* Encrypt a plaintext field value using AES-256-GCM with the field's CryptoKey.
*
* AAD binds the ciphertext to the field name and key version, preventing ciphertext
* swapping between fields. A fresh random IV is generated each call.
*/
export async function encryptField(
plaintext: string,
fieldKey: CryptoKey,
fieldName: FieldName,
): Promise<EncryptedFieldData> {
const plaintextBytes = new TextEncoder().encode(plaintext) as Uint8Array<ArrayBuffer>
const iv = generateIV()
const aad = encodeAAD(fieldName, FIELD_KEY_VERSION)
const ciphertext = await encrypt(plaintextBytes, fieldKey, { iv, aad })
return { ciphertext, iv }
}

/**
* Decrypt an encrypted field value back to a plaintext string.
*
* The AAD is reconstructed from fieldName + FIELD_KEY_VERSION, so the caller
* must pass the same field name used during encryption.
*/
export async function decryptField(
encryptedData: EncryptedFieldData,
fieldKey: CryptoKey,
fieldName: FieldName,
): Promise<string> {
const aad = encodeAAD(fieldName, FIELD_KEY_VERSION)
const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.iv, aad })
return new TextDecoder().decode(plaintextBytes)
}

/** Convert internal binary EncryptedFieldData to hex-string SaveFieldData for the API. */
export function toSaveFieldData(encryptedData: EncryptedFieldData): SaveFieldData {
return {
encryptedBlob: hexEncode(encryptedData.ciphertext),
iv: hexEncode(encryptedData.iv),
}
}

/** Convert hex-string ServerEncryptedField from the API to binary EncryptedFieldData. */
export function toEncryptedFieldData(serverField: ServerEncryptedField): EncryptedFieldData {
return {
ciphertext: hexDecode(serverField.encryptedBlob),
iv: hexDecode(serverField.iv),
}
}
Loading
Loading