241 lines
7.0 KiB
TypeScript
241 lines
7.0 KiB
TypeScript
|
|
/**
|
||
|
|
* Encryption utilities for securing sensitive user data
|
||
|
|
* Uses Web Crypto API with AES-GCM for authenticated encryption
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Generate a key from a password using PBKDF2
|
||
|
|
async function deriveKey(password: string, salt: BufferSource): Promise<CryptoKey> {
|
||
|
|
const encoder = new TextEncoder();
|
||
|
|
const keyMaterial = await crypto.subtle.importKey(
|
||
|
|
"raw",
|
||
|
|
encoder.encode(password),
|
||
|
|
"PBKDF2",
|
||
|
|
false,
|
||
|
|
["deriveBits", "deriveKey"]
|
||
|
|
);
|
||
|
|
|
||
|
|
return crypto.subtle.deriveKey(
|
||
|
|
{
|
||
|
|
name: "PBKDF2",
|
||
|
|
salt: salt,
|
||
|
|
iterations: 100000,
|
||
|
|
hash: "SHA-256",
|
||
|
|
},
|
||
|
|
keyMaterial,
|
||
|
|
{ name: "AES-GCM", length: 256 },
|
||
|
|
false,
|
||
|
|
["encrypt", "decrypt"]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get or create encryption key from localStorage
|
||
|
|
async function getEncryptionKey(): Promise<CryptoKey> {
|
||
|
|
const STORAGE_KEY = "encryption_salt";
|
||
|
|
const PASSWORD_KEY = "encryption_password";
|
||
|
|
|
||
|
|
// Generate a unique password based on user's browser fingerprint
|
||
|
|
// This creates a consistent key per browser/device
|
||
|
|
const getBrowserFingerprint = (): string => {
|
||
|
|
try {
|
||
|
|
const canvas = document.createElement("canvas");
|
||
|
|
const ctx = canvas.getContext("2d");
|
||
|
|
if (ctx) {
|
||
|
|
ctx.textBaseline = "top";
|
||
|
|
ctx.font = "14px 'Arial'";
|
||
|
|
ctx.textBaseline = "alphabetic";
|
||
|
|
ctx.fillStyle = "#f60";
|
||
|
|
ctx.fillRect(125, 1, 62, 20);
|
||
|
|
ctx.fillStyle = "#069";
|
||
|
|
ctx.fillText("Browser fingerprint", 2, 15);
|
||
|
|
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
|
||
|
|
ctx.fillText("Browser fingerprint", 4, 17);
|
||
|
|
}
|
||
|
|
const fingerprint = (canvas.toDataURL() || "") +
|
||
|
|
(navigator.userAgent || "") +
|
||
|
|
(navigator.language || "") +
|
||
|
|
(screen.width || 0) +
|
||
|
|
(screen.height || 0) +
|
||
|
|
(new Date().getTimezoneOffset() || 0);
|
||
|
|
return fingerprint;
|
||
|
|
} catch (error) {
|
||
|
|
// Fallback if canvas fingerprinting fails
|
||
|
|
return (navigator.userAgent || "") +
|
||
|
|
(navigator.language || "") +
|
||
|
|
(screen.width || 0) +
|
||
|
|
(screen.height || 0);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let salt = localStorage.getItem(STORAGE_KEY);
|
||
|
|
let password = localStorage.getItem(PASSWORD_KEY);
|
||
|
|
|
||
|
|
if (!salt || !password) {
|
||
|
|
// Generate new salt and password
|
||
|
|
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
|
||
|
|
salt = Array.from(saltBytes)
|
||
|
|
.map(b => b.toString(16).padStart(2, "0"))
|
||
|
|
.join("");
|
||
|
|
|
||
|
|
password = getBrowserFingerprint();
|
||
|
|
|
||
|
|
localStorage.setItem(STORAGE_KEY, salt);
|
||
|
|
localStorage.setItem(PASSWORD_KEY, password);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Convert hex string back to Uint8Array
|
||
|
|
const saltBytes = salt.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [];
|
||
|
|
const saltArray = new Uint8Array(saltBytes);
|
||
|
|
|
||
|
|
return deriveKey(password, saltArray);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Encrypt a string value
|
||
|
|
export async function encryptValue(value: string): Promise<string> {
|
||
|
|
if (!value || typeof window === "undefined") return value;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const key = await getEncryptionKey();
|
||
|
|
const encoder = new TextEncoder();
|
||
|
|
const data = encoder.encode(value);
|
||
|
|
|
||
|
|
// Generate a random IV for each encryption
|
||
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
|
|
|
||
|
|
const encrypted = await crypto.subtle.encrypt(
|
||
|
|
{
|
||
|
|
name: "AES-GCM",
|
||
|
|
iv: iv,
|
||
|
|
},
|
||
|
|
key,
|
||
|
|
data
|
||
|
|
);
|
||
|
|
|
||
|
|
// Combine IV and encrypted data
|
||
|
|
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||
|
|
combined.set(iv);
|
||
|
|
combined.set(new Uint8Array(encrypted), iv.length);
|
||
|
|
|
||
|
|
// Convert to base64 for storage
|
||
|
|
const binaryString = String.fromCharCode(...combined);
|
||
|
|
return btoa(binaryString);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Encryption error:", error);
|
||
|
|
// If encryption fails, return original value (graceful degradation)
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decrypt a string value
|
||
|
|
export async function decryptValue(encryptedValue: string): Promise<string> {
|
||
|
|
if (!encryptedValue || typeof window === "undefined") return encryptedValue;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const key = await getEncryptionKey();
|
||
|
|
|
||
|
|
// Decode from base64
|
||
|
|
const binaryString = atob(encryptedValue);
|
||
|
|
const combined = Uint8Array.from(binaryString, c => c.charCodeAt(0));
|
||
|
|
|
||
|
|
// Extract IV and encrypted data
|
||
|
|
const iv = combined.slice(0, 12);
|
||
|
|
const encrypted = combined.slice(12);
|
||
|
|
|
||
|
|
const decrypted = await crypto.subtle.decrypt(
|
||
|
|
{
|
||
|
|
name: "AES-GCM",
|
||
|
|
iv: iv,
|
||
|
|
},
|
||
|
|
key,
|
||
|
|
encrypted
|
||
|
|
);
|
||
|
|
|
||
|
|
const decoder = new TextDecoder();
|
||
|
|
return decoder.decode(decrypted);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Decryption error:", error);
|
||
|
|
// If decryption fails, try to return as-is (might be unencrypted legacy data)
|
||
|
|
return encryptedValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Encrypt sensitive fields in a user object
|
||
|
|
export async function encryptUserData(user: any): Promise<any> {
|
||
|
|
if (!user || typeof window === "undefined") return user;
|
||
|
|
|
||
|
|
const encrypted = { ...user };
|
||
|
|
|
||
|
|
// Encrypt sensitive fields
|
||
|
|
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
||
|
|
|
||
|
|
for (const field of sensitiveFields) {
|
||
|
|
if (encrypted[field]) {
|
||
|
|
encrypted[field] = await encryptValue(String(encrypted[field]));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return encrypted;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decrypt sensitive fields in a user object
|
||
|
|
export async function decryptUserData(user: any): Promise<any> {
|
||
|
|
if (!user || typeof window === "undefined") return user;
|
||
|
|
|
||
|
|
const decrypted = { ...user };
|
||
|
|
|
||
|
|
// Decrypt sensitive fields
|
||
|
|
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
||
|
|
|
||
|
|
for (const field of sensitiveFields) {
|
||
|
|
if (decrypted[field]) {
|
||
|
|
try {
|
||
|
|
decrypted[field] = await decryptValue(String(decrypted[field]));
|
||
|
|
} catch (error) {
|
||
|
|
// If decryption fails, keep original value (might be unencrypted)
|
||
|
|
console.warn(`Failed to decrypt field ${field}:`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return decrypted;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if a value is encrypted (heuristic check)
|
||
|
|
function isEncrypted(value: string): boolean {
|
||
|
|
// Encrypted values are base64 encoded and have a specific structure
|
||
|
|
// This is a simple heuristic - encrypted values will be longer and base64-like
|
||
|
|
if (!value || value.length < 20) return false;
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Try to decode as base64
|
||
|
|
atob(value);
|
||
|
|
// If it decodes successfully and is long enough, it's likely encrypted
|
||
|
|
return value.length > 30;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Smart encrypt/decrypt that handles both encrypted and unencrypted data
|
||
|
|
export async function smartDecryptUserData(user: any): Promise<any> {
|
||
|
|
if (!user || typeof window === "undefined") return user;
|
||
|
|
|
||
|
|
const decrypted = { ...user };
|
||
|
|
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
||
|
|
|
||
|
|
for (const field of sensitiveFields) {
|
||
|
|
if (decrypted[field] && typeof decrypted[field] === "string") {
|
||
|
|
if (isEncrypted(decrypted[field])) {
|
||
|
|
try {
|
||
|
|
decrypted[field] = await decryptValue(decrypted[field]);
|
||
|
|
} catch (error) {
|
||
|
|
console.warn(`Failed to decrypt field ${field}:`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// If not encrypted, keep as-is (backward compatibility)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return decrypted;
|
||
|
|
}
|
||
|
|
|