nu
This commit is contained in:
parent
e8864cc853
commit
1d42a9a623
9 changed files with 2122 additions and 542 deletions
|
|
@ -1,17 +1,89 @@
|
|||
// Client-side PGP utilities - wraps openpgp for browser-only usage
|
||||
// Client-side PGP utilities with encrypted storage
|
||||
|
||||
export async function generateKeyPair(username, passphrase = '') {
|
||||
const DB_NAME = 'pgp_storage';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'encrypted_keys';
|
||||
|
||||
// Initialize IndexedDB
|
||||
async function initDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Derive key from passphrase using PBKDF2
|
||||
async function deriveKey(passphrase, salt) {
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
// Validate passphrase strength
|
||||
export function validatePassphrase(passphrase) {
|
||||
if (!passphrase || passphrase.length < 12) {
|
||||
return 'Passphrase must be at least 12 characters';
|
||||
}
|
||||
|
||||
// Check for complexity
|
||||
const hasUpper = /[A-Z]/.test(passphrase);
|
||||
const hasLower = /[a-z]/.test(passphrase);
|
||||
const hasNumber = /[0-9]/.test(passphrase);
|
||||
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(passphrase);
|
||||
|
||||
const complexity = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
if (complexity < 3) {
|
||||
return 'Passphrase must contain at least 3 of: uppercase, lowercase, numbers, special characters';
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
export async function generateKeyPair(username, passphrase) {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('PGP operations can only be performed in the browser');
|
||||
}
|
||||
|
||||
// Validate passphrase
|
||||
const passphraseError = validatePassphrase(passphrase);
|
||||
if (passphraseError) {
|
||||
throw new Error(passphraseError);
|
||||
}
|
||||
|
||||
const { generateKey, readKey } = await import('openpgp');
|
||||
|
||||
const { privateKey, publicKey } = await generateKey({
|
||||
type: 'rsa',
|
||||
rsaBits: 2048,
|
||||
userIDs: [{ name: username }],
|
||||
passphrase
|
||||
passphrase // Always encrypt with passphrase
|
||||
});
|
||||
|
||||
const key = await readKey({ armoredKey: publicKey });
|
||||
|
|
@ -37,11 +109,152 @@ export async function getFingerprint(publicKey) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function signMessage(message, privateKeyArmored, passphrase = '') {
|
||||
// Save encrypted private key to IndexedDB
|
||||
export async function saveEncryptedPrivateKey(passphrase, armoredKey) {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Storage operations can only be performed in the browser');
|
||||
}
|
||||
|
||||
// Validate passphrase
|
||||
const passphraseError = validatePassphrase(passphrase);
|
||||
if (passphraseError) {
|
||||
throw new Error(passphraseError);
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate random salt and IV
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Derive encryption key
|
||||
const key = await deriveKey(passphrase, salt);
|
||||
|
||||
// Encrypt the private key
|
||||
const enc = new TextEncoder();
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
enc.encode(armoredKey)
|
||||
);
|
||||
|
||||
// Store in IndexedDB
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
await store.put({
|
||||
id: 'primary_key',
|
||||
salt: Array.from(salt),
|
||||
iv: Array.from(iv),
|
||||
encrypted: Array.from(new Uint8Array(encrypted)),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save encrypted key:', error);
|
||||
throw new Error('Failed to save encrypted key');
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock and retrieve private key from IndexedDB
|
||||
export async function unlockPrivateKey(passphrase) {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Storage operations can only be performed in the browser');
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
throw new Error('Passphrase required');
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
const request = store.get('primary_key');
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No encrypted key found');
|
||||
}
|
||||
|
||||
// Reconstruct typed arrays
|
||||
const salt = new Uint8Array(data.salt);
|
||||
const iv = new Uint8Array(data.iv);
|
||||
const encrypted = new Uint8Array(data.encrypted);
|
||||
|
||||
// Derive decryption key
|
||||
const key = await deriveKey(passphrase, salt);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
key,
|
||||
encrypted
|
||||
);
|
||||
|
||||
const dec = new TextDecoder();
|
||||
return dec.decode(decrypted);
|
||||
} catch (error) {
|
||||
console.error('Failed to unlock private key:', error);
|
||||
throw new Error('Failed to unlock private key. Check your passphrase.');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if an encrypted key exists
|
||||
export async function hasEncryptedKey() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
|
||||
const data = await new Promise((resolve) => {
|
||||
const request = store.get('primary_key');
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => resolve(null);
|
||||
});
|
||||
|
||||
return !!data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove encrypted key from IndexedDB
|
||||
export async function removeEncryptedPrivateKey() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const db = await initDB();
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
await store.delete('primary_key');
|
||||
} catch (error) {
|
||||
console.error('Failed to remove encrypted key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signMessage(message, privateKeyArmored, passphrase) {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('PGP operations can only be performed in the browser');
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
throw new Error('Passphrase required to unlock private key');
|
||||
}
|
||||
|
||||
const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp');
|
||||
|
||||
const privateKey = await decryptKey({
|
||||
|
|
@ -83,17 +296,15 @@ export async function verifySignature(message, signature, publicKeyArmored) {
|
|||
}
|
||||
}
|
||||
|
||||
export function storePrivateKey(privateKey) {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('pgp_private_key', privateKey);
|
||||
// DEPRECATED - DO NOT USE
|
||||
export function storePrivateKey() {
|
||||
throw new Error('Plaintext key storage is disabled for security. Use saveEncryptedPrivateKey() instead.');
|
||||
}
|
||||
|
||||
export function getStoredPrivateKey() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('pgp_private_key');
|
||||
throw new Error('Plaintext key storage is disabled for security. Use unlockPrivateKey() instead.');
|
||||
}
|
||||
|
||||
export function removeStoredPrivateKey() {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('pgp_private_key');
|
||||
throw new Error('Plaintext key storage is disabled for security. Use removeEncryptedPrivateKey() instead.');
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { goto } from '$app/navigation';
|
|||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true
|
||||
});
|
||||
|
||||
|
|
@ -15,29 +14,21 @@ function createAuthStore() {
|
|||
async init() {
|
||||
if (!browser) return;
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
set({ user: null, token: null, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use cookie-based auth - no localStorage tokens
|
||||
try {
|
||||
const response = await fetch('/api/user/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
credentials: 'include' // Send cookies
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set({ user: data.user, token, loading: false });
|
||||
set({ user: data.user, loading: false });
|
||||
} else {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({ user: null, token: null, loading: false });
|
||||
set({ user: null, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error);
|
||||
set({ user: null, token: null, loading: false });
|
||||
set({ user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -45,14 +36,15 @@ function createAuthStore() {
|
|||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Receive httpOnly cookie
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
set({ user: data.user, token: data.token, loading: false });
|
||||
// Server sets httpOnly cookie, we just store user data
|
||||
set({ user: data.user, loading: false });
|
||||
goto('/');
|
||||
return { success: true };
|
||||
}
|
||||
|
|
@ -64,14 +56,15 @@ function createAuthStore() {
|
|||
const response = await fetch('/api/auth/pgp-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Receive httpOnly cookie
|
||||
body: JSON.stringify({ username, signature, challenge })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
set({ user: data.user, token: data.token, loading: false });
|
||||
// Server sets httpOnly cookie, we just store user data
|
||||
set({ user: data.user, loading: false });
|
||||
goto('/');
|
||||
return { success: true };
|
||||
}
|
||||
|
|
@ -83,6 +76,7 @@ function createAuthStore() {
|
|||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
|
|
@ -95,47 +89,32 @@ function createAuthStore() {
|
|||
return { success: false, error: data.error || 'Registration failed' };
|
||||
},
|
||||
|
||||
async updateColor(color) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch('/api/user/color', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ color })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// IMPORTANT: Store the new token that includes the updated color
|
||||
if (data.token) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
async updateColor(color) {
|
||||
const response = await fetch('/api/user/color', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Use cookies for auth
|
||||
body: JSON.stringify({ color })
|
||||
});
|
||||
|
||||
// Update the store with new token and user data
|
||||
update(state => ({
|
||||
...state,
|
||||
token: data.token,
|
||||
user: {
|
||||
...state.user,
|
||||
userColor: data.color,
|
||||
colorCode: data.color // Make sure both fields are updated
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Fallback if no new token (shouldn't happen with current backend)
|
||||
update(state => ({
|
||||
...state,
|
||||
user: { ...state.user, userColor: data.color, colorCode: data.color }
|
||||
}));
|
||||
}
|
||||
|
||||
return { success: true, color: data.color };
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || 'Failed to update color' };
|
||||
},
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Update the store with new user data
|
||||
update(state => ({
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
userColor: data.color,
|
||||
colorCode: data.color
|
||||
}
|
||||
}));
|
||||
|
||||
return { success: true, color: data.color };
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || 'Failed to update color' };
|
||||
},
|
||||
|
||||
updateUser(userData) {
|
||||
update(state => ({
|
||||
|
|
@ -144,9 +123,14 @@ async updateColor(color) {
|
|||
}));
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({ user: null, token: null, loading: false });
|
||||
async logout() {
|
||||
// Call logout endpoint to clear httpOnly cookie
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
set({ user: null, loading: false });
|
||||
goto('/login');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue