258 lines
7.1 KiB
TypeScript
Executable File
258 lines
7.1 KiB
TypeScript
Executable File
// Simple encryption utilities - only what's needed for the hook
|
|
const ENCRYPTION_PASSWORD_KEY_PREFIX = "realestate_app";
|
|
const ENCRYPTION_SKIP_KEY_PREFIX = "realestate_app_skip";
|
|
|
|
// Generate user-specific localStorage key
|
|
export function getUserEncryptionKey(userId: string): string {
|
|
return `${ENCRYPTION_PASSWORD_KEY_PREFIX}_user_${userId}`;
|
|
}
|
|
|
|
// Generate user-specific skip key
|
|
export function getUserSkipKey(userId: string): string {
|
|
return `${ENCRYPTION_SKIP_KEY_PREFIX}_user_${userId}`;
|
|
}
|
|
|
|
export function MakeNewDate(
|
|
input: string | number,
|
|
timezone: string = "T00:00:00.000Z",
|
|
): Date {
|
|
if (typeof input === "string") {
|
|
if (input.length === 10) {
|
|
// If it's a 10-character string (YYYY-MM-DD), append T00:00:00.000Z for consistent UTC midnight parsing
|
|
const d = new Date(input + timezone);
|
|
return d;
|
|
} else if (input.length === 13) {
|
|
// If it's a 13-character string, try to parse as timestamp
|
|
const timestamp = parseInt(input, 10);
|
|
if (isNaN(timestamp)) {
|
|
throw new Error("Invalid 13-character timestamp string");
|
|
}
|
|
const tDate = new Date(timestamp);
|
|
const d = MakeNewDate(tDate.toISOString().split("T")[0]);
|
|
return d;
|
|
} else {
|
|
throw new Error(
|
|
"String input must be exactly 10 characters (YYYY-MM-DD) or 13 characters (timestamp)",
|
|
);
|
|
}
|
|
} else if (typeof input === "number") {
|
|
// If it's a number (timestamp), use it directly
|
|
const tDate = new Date(input);
|
|
const d = MakeNewDate(tDate.toISOString().split("T")[0]);
|
|
return d;
|
|
} else {
|
|
throw new Error("MakeNewDate expects a string or number input");
|
|
}
|
|
}
|
|
|
|
export function getDate(dateTime: Date): number {
|
|
const d = MakeNewDate(dateTime.getTime());
|
|
d.setDate(d.getDate() + 1);
|
|
return d.getDate();
|
|
}
|
|
|
|
export function getMonth(dateTime: Date): number {
|
|
const d = MakeNewDate(dateTime.getTime());
|
|
d.setDate(d.getDate() + 1);
|
|
return d.getMonth();
|
|
}
|
|
|
|
export function getYear(dateTime: Date): number {
|
|
const d = MakeNewDate(dateTime.getTime());
|
|
d.setDate(d.getDate() + 1);
|
|
return d.getFullYear();
|
|
}
|
|
|
|
export function getDay(dateTime: Date): number {
|
|
const d = MakeNewDate(dateTime.getTime());
|
|
d.setDate(d.getDate() + 1);
|
|
return d.getDay();
|
|
}
|
|
|
|
export async function createPublicId(guid: string): Promise<string> {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(guid);
|
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
return btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
|
|
.replace(/[+/=]/g, "")
|
|
.substring(0, 22); // Truncate for shorter ID
|
|
}
|
|
|
|
// Utility: derive an AES key from a password using PBKDF2
|
|
export async function deriveKey(
|
|
password: string,
|
|
salt: Uint8Array,
|
|
): Promise<CryptoKey> {
|
|
const enc = new TextEncoder();
|
|
const passKey = await globalThis.crypto.subtle.importKey(
|
|
"raw",
|
|
enc.encode(password),
|
|
"PBKDF2",
|
|
false,
|
|
["deriveKey"],
|
|
);
|
|
// Derive a 256-bit AES-GCM key
|
|
return globalThis.crypto.subtle.deriveKey(
|
|
{
|
|
name: "PBKDF2",
|
|
salt: new Uint8Array(salt),
|
|
iterations: 100_000,
|
|
hash: "SHA-256",
|
|
},
|
|
passKey,
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"],
|
|
);
|
|
}
|
|
|
|
// Utility: random salt/IV generation
|
|
export function getRandomBytes(length: number): Uint8Array {
|
|
const arr = new Uint8Array(length);
|
|
globalThis.crypto.getRandomValues(arr);
|
|
return arr;
|
|
}
|
|
|
|
// Encrypt data using password
|
|
export async function encryptData(
|
|
data: string,
|
|
password: string,
|
|
): Promise<string | null> {
|
|
if (!data || !password) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const salt = getRandomBytes(16);
|
|
const iv = getRandomBytes(12);
|
|
const key = await deriveKey(password, salt);
|
|
const enc = new TextEncoder().encode(data);
|
|
const ciphertext = new Uint8Array(
|
|
await globalThis.crypto.subtle.encrypt(
|
|
{ name: "AES-GCM", iv: new Uint8Array(iv) },
|
|
key,
|
|
new Uint8Array(enc),
|
|
),
|
|
);
|
|
|
|
// Bundle data as base64
|
|
const bundle = JSON.stringify({
|
|
salt: btoa(String.fromCharCode(...salt)),
|
|
iv: btoa(String.fromCharCode(...iv)),
|
|
data: btoa(String.fromCharCode(...ciphertext)),
|
|
});
|
|
|
|
return bundle;
|
|
} catch (error) {
|
|
console.error("Encryption failed:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decrypt data using password
|
|
export async function decryptData(
|
|
encryptedBundle: string,
|
|
password: string,
|
|
): Promise<string | null> {
|
|
if (!encryptedBundle || !password) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const { salt, iv, data } = JSON.parse(encryptedBundle);
|
|
|
|
const saltBytes = Uint8Array.from(atob(salt), (c) => c.charCodeAt(0));
|
|
const ivBytes = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
|
|
const cipherBytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
|
const key = await deriveKey(password, saltBytes);
|
|
|
|
const decryptedBytes = await globalThis.crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: new Uint8Array(ivBytes) },
|
|
key,
|
|
new Uint8Array(cipherBytes),
|
|
);
|
|
|
|
return new TextDecoder().decode(decryptedBytes);
|
|
} catch (error) {
|
|
console.error("Decryption failed:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Retrieve encryption password from localStorage
|
|
export function getEncryptionPassword(userId: string): string | null {
|
|
if (!userId) return null;
|
|
|
|
try {
|
|
const userKey = getUserEncryptionKey(userId);
|
|
return globalThis.localStorage?.getItem(userKey) || null;
|
|
} catch (error) {
|
|
console.warn(
|
|
"Failed to access localStorage for encryption password:",
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Encrypt data using stored password for a specific user (convenience function)
|
|
export async function encryptDataWithStoredPassword(
|
|
userId: string,
|
|
data: string,
|
|
): Promise<string | null> {
|
|
if (!userId) {
|
|
console.warn("Cannot encrypt data: userId is required");
|
|
return null;
|
|
}
|
|
|
|
const password = getEncryptionPassword(userId);
|
|
if (!password) {
|
|
console.warn(`No encryption password stored for user ${userId}`);
|
|
return null;
|
|
}
|
|
const encryptedBundle = await encryptData(data, password);
|
|
if (!encryptedBundle) {
|
|
return null;
|
|
}
|
|
return btoa(encryptedBundle);
|
|
}
|
|
|
|
// Decrypt data using stored password for a specific user (convenience function)
|
|
export async function decryptDataWithStoredPassword(
|
|
userId: string,
|
|
encryptedBundle: string,
|
|
): Promise<string | null> {
|
|
if (!userId) {
|
|
console.warn("Cannot decrypt data: userId is required");
|
|
return null;
|
|
}
|
|
|
|
const password = getEncryptionPassword(userId);
|
|
if (!password) {
|
|
console.warn(`No encryption password stored for user ${userId}`);
|
|
return null;
|
|
}
|
|
if (!encryptedBundle) {
|
|
return null;
|
|
}
|
|
const decodedBundle = atob(encryptedBundle);
|
|
return await decryptData(decodedBundle, password);
|
|
}
|
|
|
|
// Decrypt data using stored password for a specific user (convenience function)
|
|
export async function tryDecryptDataWith(
|
|
password: string,
|
|
encryptedBundle: string,
|
|
): Promise<string | null> {
|
|
if (!password) {
|
|
console.warn("Cannot decrypt data: password is required");
|
|
return null;
|
|
}
|
|
|
|
if (!encryptedBundle) {
|
|
return null;
|
|
}
|
|
const decodedBundle = atob(encryptedBundle);
|
|
return await decryptData(decodedBundle, password);
|
|
}
|