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 src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog'
import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog'
import { RegenerateMnemonicDialog } from '@/features/auth/ui/RegenerateMnemonicDialog'
import { VerifyMnemonicDialog } from '@/features/auth/ui/VerifyMnemonicDialog'
import { RotateFieldKeyDialog } from '@/features/fields/ui/RotateFieldKeyDialog'
import { OfflineBanner } from '@/shared/ui/OfflineBanner'
import { useLayoutStore } from './layout-store'
import { useResizable } from '@/shared/lib/use-resizable'
Expand Down Expand Up @@ -107,6 +108,7 @@ function AuthenticatedLayout() {
<ChangePasswordDialog />
<RegenerateMnemonicDialog />
<VerifyMnemonicDialog />
<RotateFieldKeyDialog />
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions src/features/auth/model/auth-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const cryptoStoreState = {
lastActivity: 0,
cachedEnvelope: null as import('@/shared/types/api.types').CachedVaultEnvelope | null,
setCachedEnvelope: mockSetEnvelope,
clearCachedEnvelope: vi.fn(),
lockVault: vi.fn<() => void>(),
clearVault: mockClearVault,
}
Expand Down
2 changes: 2 additions & 0 deletions src/features/auth/model/mnemonic-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,12 @@ describe('regenerateMnemonic', () => {
lastActivity: 0,
cachedEnvelope: null,
setCachedEnvelope: mockSetCachedEnvelope,
clearCachedEnvelope: vi.fn(),
markKeysLoaded: vi.fn(),
lockVault: vi.fn(),
clearVault: vi.fn(),
updateActivity: vi.fn(),
updateCachedFieldKey: vi.fn(),
} as ReturnType<typeof useCryptoStore.getState>)

await regenerateMnemonic('password')
Expand Down
4 changes: 2 additions & 2 deletions src/features/auth/model/registration-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { zeroFill, generateSalt } from '@/shared/crypto/core/crypto-utils'
import { createRecoveryData } from '@/shared/crypto/keys/mnemonic'
import { generateAndWrapFieldKeys } from '@/shared/crypto/keys/field-keys'
import { generateAllFieldKeys } from '@/shared/crypto/keys/field-keys'
import { deriveAuthCredentials } from '@/shared/crypto/keys/split-kdf'
import { generateMasterKey, wrapMasterKeyWithPassword } from '@/shared/crypto/keys/master-key'
import { deriveKEK } from '@/shared/crypto/core/hkdf'
Expand Down Expand Up @@ -29,7 +29,7 @@ export async function deriveRegistrationKeys(password: string): Promise<Registra

const kek = await importKey(kekBytes)
zeroFill(kekBytes)
const { cryptoFieldKeys, wrappedFieldKeys } = await generateAndWrapFieldKeys(kek)
const { cryptoFieldKeys, wrappedFieldKeys } = await generateAllFieldKeys(kek)

return {
authHash,
Expand Down
10 changes: 5 additions & 5 deletions src/features/fields/model/field-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { decrypt, encrypt } from '@/shared/crypto/core/aes-gcm'
import { encodeAAD, generateIV, hexDecode, hexEncode } from '@/shared/crypto/core/crypto-utils'
import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types'
import { FIELD_CONTENT_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
* AAD binds the ciphertext to the field name and content version, preventing ciphertext
* swapping between fields. A fresh random IV is generated each call.
*/
export async function encryptField(
Expand All @@ -18,23 +18,23 @@ export async function encryptField(
): Promise<EncryptedFieldData> {
const plaintextBytes = new TextEncoder().encode(plaintext) as Uint8Array<ArrayBuffer>
const ciphertextIV = generateIV()
const aad = encodeAAD(fieldName, FIELD_KEY_VERSION)
const aad = encodeAAD(fieldName, FIELD_CONTENT_VERSION)
const ciphertext = await encrypt(plaintextBytes, fieldKey, { iv: ciphertextIV, aad })
return { ciphertext, ciphertextIV }
}

/**
* Decrypt an encrypted field value back to a plaintext string.
*
* The AAD is reconstructed from fieldName + FIELD_KEY_VERSION, so the caller
* The AAD is reconstructed from fieldName + FIELD_CONTENT_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 aad = encodeAAD(fieldName, FIELD_CONTENT_VERSION)
const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.ciphertextIV, aad })
return new TextDecoder().decode(plaintextBytes)
}
Expand Down
34 changes: 34 additions & 0 deletions src/features/fields/model/key-rotation-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { TFunction } from 'i18next'
import { DecryptionError } from '@/shared/crypto/core/errors'
import { AuthErrorCode } from '@/shared/auth/auth-errors'
import { ApiErrorCode } from '@/shared/api/api-errors'
import { mapErrorToMessage, type ErrorKeySpec } from '@/shared/lib/error-messages'

/**
* Error spec for field-key rotation.
*
* The RPC is atomic, so any failure means nothing changed server-side. The
* messages reflect that: a failed rotation never leaves the vault in a broken
* state. The fallback covers the vault-locked throw and any other generic
* Error.
*/
const KEY_ROTATION_ERROR_SPEC: ErrorKeySpec = {
instanceChecks: [[DecryptionError, 'rotation.staleVault']],
authCodes: {
[AuthErrorCode.NETWORK_ERROR]: 'rotation.networkError',
},
apiCodes: {
[ApiErrorCode.NETWORK_ERROR]: 'rotation.networkError',
[ApiErrorCode.UNEXPECTED]: 'rotation.failed',
},
networkKey: 'rotation.networkError',
fallbackKey: 'rotation.locked',
}

/**
* Maps field-key rotation errors to user-facing i18n strings in the 'vault'
* namespace.
*/
export function getKeyRotationErrorMessage(error: unknown, t: TFunction<'vault'>): string {
return mapErrorToMessage(error, t, KEY_ROTATION_ERROR_SPEC)
}
Loading
Loading