273 lines
7.3 KiB
TypeScript
273 lines
7.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) {
|
|
console.log(
|
|
"[Encryption] decryptDataWithStoredPassword: no PIN for userId, skipping decrypt",
|
|
);
|
|
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
|
|
console.log(
|
|
"[Encryption] decryptDataWithStoredPassword: legacy base64, returning decoded",
|
|
);
|
|
return decoded;
|
|
}
|
|
if (isEnvelope(parsed)) {
|
|
const out = await decryptEnvelope(password, parsed);
|
|
console.log(
|
|
"[Encryption] decryptDataWithStoredPassword: envelope decrypted ok",
|
|
);
|
|
return out;
|
|
}
|
|
// JSON but not an envelope: legacy
|
|
console.log(
|
|
"[Encryption] decryptDataWithStoredPassword: JSON payload, returning as-is",
|
|
);
|
|
return decoded;
|
|
} catch (e) {
|
|
console.log("[Encryption] decryptDataWithStoredPassword: failed", e);
|
|
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) {
|
|
console.log("[Encryption] getEncryptionPassword: no userId");
|
|
return null;
|
|
}
|
|
try {
|
|
const pin = globalThis.localStorage?.getItem(
|
|
ENCRYPTION_KEY_PREFIX + userId,
|
|
);
|
|
console.log(
|
|
"[Encryption] getEncryptionPassword:",
|
|
pin ? "PIN available" : "no PIN",
|
|
"key=" + 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) {
|
|
console.log("[Encryption] setEncryptionPassword: no userId, skip");
|
|
return false;
|
|
}
|
|
try {
|
|
const key = getUserEncryptionKey(userId);
|
|
if (key) globalThis.localStorage?.setItem(key, password);
|
|
console.log("[Encryption] setEncryptionPassword: stored for key=" + key);
|
|
return true;
|
|
} catch (e) {
|
|
console.log("[Encryption] setEncryptionPassword: failed", e);
|
|
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);
|
|
}
|