integratedhub/helpers.ts

251 lines
6.3 KiB
TypeScript

// Helper functions for the application
// Date utility functions
export function MakeNewDate(input: string | number | Date): Date {
if (input instanceof Date) {
return input;
}
return new Date(input);
}
export function getDate(date: Date): number {
return date.getDate();
}
export function getMonth(date: Date): number {
return date.getMonth();
}
export function getYear(date: Date): number {
return date.getFullYear();
}
export function getDay(date: Date): number {
return date.getDay();
}
export function getDaysInBaseMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
// ---------------------------------------------------------------------------
// Encryption: envelope format { salt, iv, data } (base64), AES-GCM + PBKDF2
// ---------------------------------------------------------------------------
const PBKDF2_ITERATIONS = 100_000;
const AES_KEY_LENGTH = 256;
/** Decode base64 to a new Uint8Array (ArrayBuffer-backed for Web Crypto). */
function base64ToBytes(base64: string): Uint8Array {
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Uint8Array(bytes);
}
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
/** Decrypt AES-GCM ciphertext using key derived from password + salt. */
async function decryptEnvelope(
password: string,
envelope: { salt: string; iv: string; data: string },
): Promise<string> {
const saltBytes = base64ToBytes(envelope.salt);
const ivBytes = base64ToBytes(envelope.iv);
const ciphertextBytes = base64ToBytes(envelope.data);
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: saltBytes as BufferSource,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: AES_KEY_LENGTH },
false,
["decrypt"],
);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivBytes as BufferSource },
key,
ciphertextBytes as BufferSource,
);
return new TextDecoder().decode(plaintext);
}
/** True if the decoded payload is an encrypted envelope we can decrypt. */
function isEnvelope(
obj: unknown,
): obj is { salt: string; iv: string; data: string } {
return (
typeof obj === "object" &&
obj !== null &&
"salt" in obj &&
"iv" in obj &&
"data" in obj &&
typeof (obj as { salt: unknown }).salt === "string" &&
typeof (obj as { iv: unknown }).iv === "string" &&
typeof (obj as { data: unknown }).data === "string"
);
}
export function encryptDataWithStoredPassword(
_userId: string,
data: string,
): string {
// Legacy: plain base64 for backward compatibility when no envelope is used
return btoa(data);
}
/**
* Decrypt data using the PIN stored in localStorage for this user.
* Supports: (1) encrypted envelope { salt, iv, data } then AES-GCM decrypt;
* (2) legacy plain base64 string.
*/
export async function decryptDataWithStoredPassword(
userId: string,
encryptedData: string,
): Promise<string> {
const password = getEncryptionPassword(userId);
if (!password) {
return "";
}
try {
const decoded = atob(encryptedData);
let parsed: unknown;
try {
parsed = JSON.parse(decoded) as unknown;
} catch {
// Not JSON: treat as legacy plain base64 payload
return decoded;
}
if (isEnvelope(parsed)) {
const out = await decryptEnvelope(password, parsed);
return out;
}
// JSON but not an envelope: legacy
return decoded;
} catch {
return "";
}
}
/** Decrypt using stored PIN (from localStorage by userId). Returns null on failure. */
export async function tryDecryptDataWith(
userId: string,
encryptedData: string,
): Promise<string | null> {
try {
const out = await decryptDataWithStoredPassword(userId, encryptedData);
return out || null;
} catch {
return null;
}
}
/** Decrypt using a raw PIN string (e.g. from server sync). Supports envelope or legacy base64. */
export async function tryDecryptDataWithPin(
pin: string,
encryptedData: string,
): Promise<string | null> {
if (!pin) return null;
try {
const decoded = atob(encryptedData);
let parsed: unknown;
try {
parsed = JSON.parse(decoded) as unknown;
} catch {
return decoded;
}
if (isEnvelope(parsed)) {
return await decryptEnvelope(pin, parsed);
}
return decoded;
} catch {
return null;
}
}
const ENCRYPTION_KEY_PREFIX = "encryption_password_";
export function getEncryptionPassword(userId: string): string | null {
if (!userId) {
return null;
}
try {
const pin = globalThis.localStorage?.getItem(
ENCRYPTION_KEY_PREFIX + userId,
);
return pin;
} catch {
return null;
}
}
/** Store encryption PIN in localStorage for this user (used by WelcomeBar and calendar). */
export function setEncryptionPassword(
userId: string,
password: string,
): boolean {
if (!userId) {
return false;
}
try {
const key = getUserEncryptionKey(userId);
if (key) globalThis.localStorage?.setItem(key, password);
return true;
} catch {
return false;
}
}
export function getUserEncryptionKey(userId: string): string | null {
if (!userId) return null;
return ENCRYPTION_KEY_PREFIX + userId;
}
export function getUserSkipKey(userId: string): string {
return `skip_encryption_${userId}`;
}
export function getUserSkipValue(userId: string): boolean {
const skipKey = getUserSkipKey(userId);
try {
return globalThis.localStorage?.getItem(skipKey) === "true";
} catch {
return false;
}
}
export function setUserSkipKey(userId: string, skip: boolean): void {
const skipKey = `skip_encryption_${userId}`;
try {
if (skip) {
globalThis.localStorage?.setItem(skipKey, "true");
} else {
globalThis.localStorage?.removeItem(skipKey);
}
} catch {
// Ignore localStorage errors
}
}
export function createPublicId(userId: string): string {
// Create a public ID from user ID (simple implementation)
return btoa(userId).replace(/[+/=]/g, "").substring(0, 10);
}