feat: add settings page to analytics-frontend
This commit is contained in:
40
lib/api/2fa.ts
Normal file
40
lib/api/2fa.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface Setup2FAResponse {
|
||||
secret: string
|
||||
qr_code: string
|
||||
}
|
||||
|
||||
export interface Verify2FAResponse {
|
||||
message: string
|
||||
recovery_codes: string[]
|
||||
}
|
||||
|
||||
export interface RegenerateCodesResponse {
|
||||
recovery_codes: string[]
|
||||
}
|
||||
|
||||
export async function setup2FA(): Promise<Setup2FAResponse> {
|
||||
return apiRequest<Setup2FAResponse>('/auth/2fa/setup', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function verify2FA(code: string): Promise<Verify2FAResponse> {
|
||||
return apiRequest<Verify2FAResponse>('/auth/2fa/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function disable2FA(): Promise<void> {
|
||||
return apiRequest<void>('/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCodes(): Promise<RegenerateCodesResponse> {
|
||||
return apiRequest<RegenerateCodesResponse>('/auth/2fa/recovery', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
60
lib/api/user.ts
Normal file
60
lib/api/user.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import apiRequest from './client'
|
||||
|
||||
export async function deleteAccount(password: string): Promise<void> {
|
||||
// This goes to ciphera-auth
|
||||
return apiRequest<void>('/auth/user', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
client_ip: string
|
||||
user_agent: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
export async function getUserSessions(): Promise<{ sessions: Session[] }> {
|
||||
// Hash the current refresh token to identify current session
|
||||
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null
|
||||
let currentTokenHash = ''
|
||||
|
||||
if (refreshToken) {
|
||||
// Hash the refresh token using SHA-256
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(refreshToken)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', {
|
||||
headers: currentTokenHash ? {
|
||||
'X-Current-Session-Hash': currentTokenHash,
|
||||
} : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
return apiRequest<void>(`/auth/user/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
email_notifications: {
|
||||
new_file_received: boolean
|
||||
file_downloaded: boolean
|
||||
security_alerts: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserPreferences(preferences: UserPreferences): Promise<void> {
|
||||
return apiRequest<void>('/auth/user/preferences', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(preferences),
|
||||
})
|
||||
}
|
||||
133
lib/crypto/password.ts
Normal file
133
lib/crypto/password.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Password protection utilities using PBKDF2
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derive a key from password using PBKDF2
|
||||
* This is used to encrypt the file encryption key when password protection is enabled
|
||||
*/
|
||||
export async function deriveKeyFromPassword(
|
||||
password: string,
|
||||
salt: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
// * Import password as key material
|
||||
const passwordKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
)
|
||||
|
||||
// * Derive key using PBKDF2
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt as BufferSource,
|
||||
iterations: 100000, // * High iteration count for security
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
passwordKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256,
|
||||
},
|
||||
false, // not extractable
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an authentication key from password and email (used as salt).
|
||||
* This ensures the raw password never leaves the client.
|
||||
*/
|
||||
export async function deriveAuthKey(
|
||||
password: string,
|
||||
email: string
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// * Import password as key material
|
||||
const passwordKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
)
|
||||
|
||||
// * Derive bits using PBKDF2
|
||||
// * We use the email as a deterministic salt for the auth key
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: encoder.encode(email),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
passwordKey,
|
||||
256 // 256 bits = 32 bytes
|
||||
)
|
||||
|
||||
// * Convert to hex string
|
||||
return Array.from(new Uint8Array(derivedBits))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random salt for PBKDF2
|
||||
*/
|
||||
export function generateSalt(): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(16))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a key with a password-derived key
|
||||
*/
|
||||
export async function encryptKeyWithPassword(
|
||||
key: Uint8Array,
|
||||
password: string
|
||||
): Promise<{ encrypted: ArrayBuffer; iv: Uint8Array; salt: Uint8Array }> {
|
||||
const salt = generateSalt()
|
||||
const derivedKey = await deriveKeyFromPassword(password, salt)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv as BufferSource,
|
||||
},
|
||||
derivedKey,
|
||||
key as BufferSource
|
||||
)
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
iv,
|
||||
salt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a key using a password
|
||||
*/
|
||||
export async function decryptKeyWithPassword(
|
||||
encrypted: ArrayBuffer,
|
||||
iv: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
password: string
|
||||
): Promise<Uint8Array> {
|
||||
const derivedKey = await deriveKeyFromPassword(password, salt)
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv as BufferSource,
|
||||
},
|
||||
derivedKey,
|
||||
encrypted
|
||||
)
|
||||
|
||||
return new Uint8Array(decrypted)
|
||||
}
|
||||
Reference in New Issue
Block a user