This commit is contained in:
doomtube 2025-08-13 00:10:25 -04:00
parent e8864cc853
commit 1d42a9a623
9 changed files with 2122 additions and 542 deletions

View file

@ -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.');
}

View file

@ -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');
}
};

View file

@ -9,6 +9,8 @@
let username = '';
let password = '';
let confirmPassword = '';
let keyPassphrase = '';
let confirmKeyPassphrase = '';
let error = '';
let loading = false;
let pgpLoading = false;
@ -22,10 +24,14 @@
let showGeneratedKeys = false;
let generatedPrivateKey = '';
let generatedPublicKey = '';
let saveKeyLocally = false;
// Show PGP command example
let showPgpExample = false;
// For unlocking stored key
let storedKeyPassphrase = '';
onMount(async () => {
await auth.init();
if ($isAuthenticated) {
@ -82,11 +88,22 @@
return;
}
if (keyPassphrase !== confirmKeyPassphrase) {
error = 'Key passphrases do not match';
return;
}
const passphraseError = pgp.validatePassphrase(keyPassphrase);
if (passphraseError) {
error = passphraseError;
return;
}
loading = true;
try {
// Generate PGP key pair
const keyPair = await pgp.generateKeyPair(username);
// Generate PGP key pair with passphrase
const keyPair = await pgp.generateKeyPair(username, keyPassphrase);
const result = await auth.register({
username,
@ -96,10 +113,7 @@
});
if (result.success) {
// Store private key locally
pgp.storePrivateKey(keyPair.privateKey);
// Show keys
// Save keys for display
generatedPrivateKey = keyPair.privateKey;
generatedPublicKey = keyPair.publicKey;
showGeneratedKeys = true;
@ -107,13 +121,38 @@
error = result.error;
}
} catch (e) {
error = 'Failed to generate PGP keys';
error = e.message || 'Failed to generate PGP keys';
console.error(e);
}
loading = false;
}
async function proceedAfterKeys() {
loading = true;
try {
// If user wants to save locally, store encrypted
if (saveKeyLocally) {
await pgp.saveEncryptedPrivateKey(keyPassphrase, generatedPrivateKey);
}
// Auto-login after registration
const result = await auth.login({ username, password });
if (result.success) {
goto('/');
} else {
showGeneratedKeys = false;
mode = 'login';
error = 'Registration successful. Please login.';
}
} catch (e) {
error = 'Failed to save keys: ' + e.message;
}
loading = false;
}
async function initiatePgpLogin() {
error = '';
pgpLoading = true;
@ -133,6 +172,13 @@
// Clear the signature field
pgpSignature = '';
// Check if we have a stored key
const hasKey = await pgp.hasEncryptedKey();
if (hasKey) {
// We'll need to unlock it
storedKeyPassphrase = '';
}
} else {
error = data.error || 'User not found or PGP not enabled';
}
@ -144,6 +190,26 @@
pgpLoading = false;
}
async function signWithStoredKey() {
error = '';
loading = true;
try {
// Unlock the stored private key
const privateKey = await pgp.unlockPrivateKey(storedKeyPassphrase);
// Sign the challenge
pgpSignature = await pgp.signMessage(pgpChallenge, privateKey, storedKeyPassphrase);
// Clear passphrase for security
storedKeyPassphrase = '';
} catch (e) {
error = 'Failed to unlock key or sign: ' + e.message;
}
loading = false;
}
async function handlePgpLogin() {
error = '';
loading = true;
@ -172,6 +238,7 @@
pgpChallenge = '';
pgpPublicKey = '';
pgpSignature = '';
storedKeyPassphrase = '';
error = '';
}
@ -189,27 +256,13 @@
a.click();
URL.revokeObjectURL(url);
}
async function proceedAfterKeys() {
// Auto-login after registration
loading = true;
const result = await auth.login({ username, password });
if (result.success) {
goto('/');
} else {
showGeneratedKeys = false;
mode = 'login';
error = 'Registration successful. Please login.';
}
loading = false;
}
</script>
<div class="auth-container">
{#if showGeneratedKeys}
<h1>Your PGP Keys</h1>
<p style="color: var(--error); margin-bottom: 1rem;">
<strong>Important:</strong> Save your private key securely. You will need it to login with PGP.
<strong>Important:</strong> Save your private key securely. You will need it and your passphrase to login with PGP.
</p>
<div class="form-group">
@ -231,7 +284,7 @@
</div>
<div class="form-group">
<label>Private Key</label>
<label>Private Key (Encrypted with your passphrase)</label>
<textarea
readonly
rows="10"
@ -243,13 +296,27 @@
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${username}-private-key.asc`)}>
Download
Download (REQUIRED!)
</button>
</div>
</div>
<div class="form-group" style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); padding: 1rem; border-radius: 4px;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<input
type="checkbox"
bind:checked={saveKeyLocally}
style="width: 20px; height: 20px;"
/>
Save encrypted key in this browser for easier PGP login
</label>
<small style="color: var(--gray); display: block; margin-top: 0.5rem;">
The key will be encrypted with your passphrase and stored locally. You can export or remove it later from Settings.
</small>
</div>
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
{loading ? 'Logging in...' : 'Continue'}
{loading ? 'Saving...' : 'Continue'}
</button>
{:else}
<h1>{mode === 'login' ? 'Login' : 'Register'}</h1>
@ -303,6 +370,37 @@
<form on:submit|preventDefault={handlePgpLogin}>
<h3 style="margin-bottom: 1rem;">PGP Authentication</h3>
{#await pgp.hasEncryptedKey() then hasKey}
{#if hasKey && !pgpSignature}
<div class="instruction-box" style="background: rgba(40, 167, 69, 0.1); border-color: rgba(40, 167, 69, 0.3);">
<p><strong>Stored key detected!</strong></p>
<p>Enter your passphrase to unlock your private key and sign automatically:</p>
<div class="form-group" style="margin: 1rem 0;">
<input
type="password"
bind:value={storedKeyPassphrase}
placeholder="Enter your key passphrase"
disabled={loading}
/>
</div>
<button
type="button"
on:click={signWithStoredKey}
disabled={loading || !storedKeyPassphrase}
style="margin-bottom: 0.5rem;"
>
{loading ? 'Signing...' : 'Unlock & Sign'}
</button>
</div>
<div style="margin: 1rem 0; text-align: center; color: var(--gray);">
OR sign manually
</div>
{/if}
{/await}
<div class="instruction-box">
<p><strong>Step 1:</strong> Copy this challenge text:</p>
<div class="pgp-key" style="margin: 0.5rem 0;">
@ -319,7 +417,7 @@
<p><strong>Step 2:</strong> Sign it with your private key using GPG or another PGP tool</p>
<p style="color: var(--gray); font-size: 0.85rem; margin-top: 0.5rem;">
Note: For security, we never handle your private keys. All signing must be done on your device.
Note: Your private key passphrase is required to unlock your key for signing.
</p>
</div>
@ -339,7 +437,7 @@
# Save the challenge to a file
echo "{pgpChallenge}" > challenge.txt
# Sign with your private key
# Sign with your private key (will prompt for passphrase)
gpg --armor --detach-sign challenge.txt
# This creates challenge.txt.asc with the signature
@ -350,16 +448,9 @@ gpg --armor --detach-sign challenge.txt
<li>Save the challenge text to a file</li>
<li>Right-click the file → Sign</li>
<li>Select your key and choose "Create detached signature"</li>
<li>Enter your passphrase when prompted</li>
<li>Open the .asc file and copy its contents</li>
</ol>
<h4>Using GPG Suite (Mac):</h4>
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
<li>Save the challenge text to a file</li>
<li>Right-click the file → Services → OpenPGP: Sign File</li>
<li>Choose "Create Detached Signature"</li>
<li>Open the .sig file and copy its contents</li>
</ol>
</div>
{/if}
@ -442,6 +533,40 @@ iQEcBAABCAAGBQJe...
/>
</div>
<div style="background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); padding: 1rem; border-radius: 8px; margin: 1.5rem 0;">
<h3 style="margin: 0 0 1rem 0; font-size: 1.1rem; color: var(--primary);">🔐 PGP Key Passphrase</h3>
<p style="font-size: 0.9rem; color: var(--gray); margin-bottom: 1rem;">
This passphrase encrypts your PGP private key. You'll need it every time you use PGP login.
</p>
<div class="form-group">
<label for="key-passphrase">Key Passphrase</label>
<input
type="password"
id="key-passphrase"
bind:value={keyPassphrase}
required
disabled={loading}
placeholder="Enter a strong passphrase (12+ characters)"
/>
<small style="color: var(--gray);">
Must be 12+ characters with 3 of: uppercase, lowercase, numbers, special characters
</small>
</div>
<div class="form-group">
<label for="confirm-key-passphrase">Confirm Key Passphrase</label>
<input
type="password"
id="confirm-key-passphrase"
bind:value={confirmKeyPassphrase}
required
disabled={loading}
placeholder="Confirm your key passphrase"
/>
</div>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
@ -451,18 +576,18 @@ iQEcBAABCAAGBQJe...
</button>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--gray);">
A PGP key pair will be generated for your account. You'll be able to save both keys after registration.
A PGP key pair will be generated and encrypted with your passphrase.
</p>
</form>
{/if}
<div style="margin-top: 2rem; text-align: center;">
{#if mode === 'login'}
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; }}>
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; username = ''; password = ''; }}>
Need an account? Register
</button>
{:else}
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; }}>
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; username = ''; password = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}>
Already have an account? Login
</button>
{/if}
@ -531,4 +656,14 @@ iQEcBAABCAAGBQJe...
.example-section ol li {
margin-bottom: 0.25rem;
}
.pgp-key {
font-family: monospace;
font-size: 0.8rem;
background: rgba(255, 255, 255, 0.05);
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View file

@ -54,6 +54,14 @@
let showGeneratedKeys = false;
let generatedPrivateKey = '';
let generatedPublicKey = '';
let keyPassphrase = '';
let confirmKeyPassphrase = '';
let saveKeyLocally = false;
let hasEncryptedKey = false;
// Unlock key dialog
let showUnlockDialog = false;
let unlockPassphrase = '';
// User data for safe access
let currentUser = null;
@ -71,10 +79,13 @@
// Store user for safe access
currentUser = $auth.user;
// Check if we have an encrypted key stored
hasEncryptedKey = await pgp.hasEncryptedKey();
// Get fresh user data to ensure we have latest values
try {
const response = await fetch('/api/user/me', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
credentials: 'include'
});
if (response.ok) {
@ -111,11 +122,11 @@
});
async function loadPgpKeys() {
if (!browser || !$auth.token) return;
if (!browser) return;
try {
const response = await fetch('/api/user/pgp-keys', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
credentials: 'include'
});
const data = await response.json();
if (data.success) {
@ -148,10 +159,8 @@
// Update bio
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ bio })
});
@ -170,9 +179,7 @@
const avatarResponse = await fetch('/api/user/avatar', {
method: 'POST',
headers: {
'Authorization': `Bearer ${$auth.token}`
},
credentials: 'include',
body: formData
});
@ -236,10 +243,8 @@
try {
const response = await fetch('/api/user/password', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ oldPassword, newPassword })
});
@ -266,37 +271,37 @@
loading = false;
}
async function updateColor() {
if (!newColor || newColor === userColor) {
colorError = 'Please select a different color';
return;
}
colorLoading = true;
colorError = '';
colorMessage = '';
const result = await auth.updateColor(newColor);
if (result.success) {
userColor = result.color;
colorMessage = 'Color updated successfully!';
showColorPicker = false;
// Refresh the current user data to ensure consistency
if (currentUser) {
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
async function updateColor() {
if (!newColor || newColor === userColor) {
colorError = 'Please select a different color';
return;
}
// Clear message after 3 seconds
setTimeout(() => { colorMessage = ''; }, 3000);
} else {
colorError = result.error || 'Failed to update color';
colorLoading = true;
colorError = '';
colorMessage = '';
const result = await auth.updateColor(newColor);
if (result.success) {
userColor = result.color;
colorMessage = 'Color updated successfully!';
showColorPicker = false;
// Refresh the current user data to ensure consistency
if (currentUser) {
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
}
// Clear message after 3 seconds
setTimeout(() => { colorMessage = ''; }, 3000);
} else {
colorError = result.error || 'Failed to update color';
}
colorLoading = false;
}
colorLoading = false;
}
function selectSuggestedColor(color) {
newColor = color;
}
@ -355,10 +360,8 @@ async function updateColor() {
try {
const response = await fetch('/api/user/pgp-only', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ enable: true })
});
@ -407,19 +410,46 @@ async function updateColor() {
async function generateNewKeyPair() {
if (!browser) return;
loading = true;
error = '';
keyPassphrase = '';
confirmKeyPassphrase = '';
saveKeyLocally = false;
// Prompt for passphrase first
showGeneratedKeys = true;
generatedPrivateKey = '';
generatedPublicKey = '';
}
async function generateKeysWithPassphrase() {
error = '';
if (!keyPassphrase) {
error = 'Passphrase is required';
return;
}
if (keyPassphrase !== confirmKeyPassphrase) {
error = 'Passphrases do not match';
return;
}
const passphraseError = pgp.validatePassphrase(keyPassphrase);
if (passphraseError) {
error = passphraseError;
return;
}
loading = true;
try {
const keyPair = await pgp.generateKeyPair(currentUser?.username || 'user');
const keyPair = await pgp.generateKeyPair(currentUser?.username || 'user', keyPassphrase);
// Show keys instead of auto-downloading
generatedPrivateKey = keyPair.privateKey;
generatedPublicKey = keyPair.publicKey;
showGeneratedKeys = true;
} catch (e) {
error = 'Failed to generate key pair';
error = 'Failed to generate key pair: ' + e.message;
console.error(e);
}
@ -431,14 +461,13 @@ async function updateColor() {
error = '';
try {
// Save public key to server
const fingerprint = await pgp.getFingerprint(generatedPublicKey);
const response = await fetch('/api/user/pgp-key', {
method: 'POST',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
publicKey: generatedPublicKey,
fingerprint: fingerprint
@ -448,8 +477,11 @@ async function updateColor() {
const data = await response.json();
if (response.ok && data.success) {
// Store private key locally
pgp.storePrivateKey(generatedPrivateKey);
// If user wants to save locally, encrypt and store
if (saveKeyLocally) {
await pgp.saveEncryptedPrivateKey(keyPassphrase, generatedPrivateKey);
hasEncryptedKey = true;
}
message = 'New key pair saved successfully';
error = '';
@ -457,6 +489,8 @@ async function updateColor() {
showGeneratedKeys = false;
generatedPrivateKey = '';
generatedPublicKey = '';
keyPassphrase = '';
confirmKeyPassphrase = '';
// Clear message after 3 seconds
setTimeout(() => { message = ''; }, 3000);
@ -487,10 +521,8 @@ async function updateColor() {
const response = await fetch('/api/user/pgp-key', {
method: 'POST',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
publicKey: newPublicKey,
fingerprint
@ -583,6 +615,35 @@ async function updateColor() {
});
}
async function showUnlockKeyDialog() {
showUnlockDialog = true;
unlockPassphrase = '';
}
async function unlockAndExportKey() {
error = '';
try {
const privateKey = await pgp.unlockPrivateKey(unlockPassphrase);
downloadKey(privateKey, `${displayUsername}-private-key.asc`);
showUnlockDialog = false;
unlockPassphrase = '';
message = 'Private key exported successfully';
setTimeout(() => { message = ''; }, 3000);
} catch (e) {
error = 'Failed to unlock key: ' + e.message;
}
}
async function removeLocalKey() {
if (confirm('Remove the encrypted private key from this browser? You will need to re-import it later to use PGP login.')) {
await pgp.removeEncryptedPrivateKey();
hasEncryptedKey = false;
message = 'Local key removed';
setTimeout(() => { message = ''; }, 3000);
}
}
// Track profile changes
$: if (browser) {
hasProfileChanges = bio !== (currentUser?.bio || '') || avatarFile !== null;
@ -910,6 +971,55 @@ async function updateColor() {
border-color: var(--white);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
.passphrase-info {
background: rgba(86, 29, 94, 0.1);
border: 1px solid rgba(86, 29, 94, 0.3);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
.passphrase-info h4 {
margin: 0 0 0.5rem 0;
color: var(--primary);
}
.passphrase-info ul {
margin: 0;
padding-left: 1.5rem;
font-size: 0.9rem;
color: var(--gray);
}
.key-storage-option {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 1rem;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
}
.local-key-info {
background: rgba(40, 167, 69, 0.1);
border: 1px solid rgba(40, 167, 69, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.local-key-info p {
margin: 0 0 0.5rem 0;
}
.local-key-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
</style>
<div class="container">
@ -1257,6 +1367,26 @@ async function updateColor() {
</div>
{/if}
{#if hasEncryptedKey}
<div class="local-key-info">
<p>
<span style="color: var(--success);">🔑</span>
<strong>Encrypted private key stored in this browser</strong>
</p>
<p style="font-size: 0.9rem; color: var(--gray);">
Your private key is encrypted and stored locally. You'll need your passphrase to use it.
</p>
<div class="local-key-actions">
<button on:click={showUnlockKeyDialog}>
Export Private Key
</button>
<button class="btn btn-danger" on:click={removeLocalKey}>
Remove Local Key
</button>
</div>
</div>
{/if}
<h3>Your PGP Keys</h3>
{#if pgpKeys.length > 0}
@ -1284,58 +1414,116 @@ async function updateColor() {
{#if showGeneratedKeys}
<div style="margin: 2rem 0; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
<h3>Generated Key Pair</h3>
<p style="color: var(--error); margin-bottom: 1rem;">
<strong>Important:</strong> Save your private key securely before proceeding.
</p>
<h3>Generate New Key Pair</h3>
<div class="form-group">
<label>Public Key</label>
<textarea
readonly
rows="8"
value={generatedPublicKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
Copy
{#if !generatedPrivateKey}
<div class="passphrase-info">
<h4>🔐 Passphrase Requirements</h4>
<ul>
<li>At least 12 characters long</li>
<li>Include 3 of: uppercase, lowercase, numbers, special characters</li>
<li>This passphrase encrypts your private key</li>
<li>You'll need it every time you use your key</li>
</ul>
</div>
<div class="form-group">
<label for="key-passphrase">Passphrase</label>
<input
type="password"
id="key-passphrase"
bind:value={keyPassphrase}
placeholder="Enter a strong passphrase"
disabled={loading}
/>
</div>
<div class="form-group">
<label for="confirm-key-passphrase">Confirm Passphrase</label>
<input
type="password"
id="confirm-key-passphrase"
bind:value={confirmKeyPassphrase}
placeholder="Confirm your passphrase"
disabled={loading}
/>
</div>
<div style="display: flex; gap: 1rem;">
<button on:click={generateKeysWithPassphrase} disabled={loading || !keyPassphrase}>
{loading ? 'Generating...' : 'Generate Keys'}
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${displayUsername}-public-key.asc`)}>
Download
<button
class="btn btn-secondary"
on:click={() => { showGeneratedKeys = false; keyPassphrase = ''; confirmKeyPassphrase = ''; }}
>
Cancel
</button>
</div>
</div>
<div class="form-group">
<label>Private Key</label>
<textarea
readonly
rows="8"
value={generatedPrivateKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
Copy
{:else}
<p style="color: var(--error); margin-bottom: 1rem;">
<strong>Important:</strong> Save your private key securely. You will need it and your passphrase to login with PGP.
</p>
<div class="form-group">
<label>Public Key</label>
<textarea
readonly
rows="8"
value={generatedPublicKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${displayUsername}-public-key.asc`)}>
Download
</button>
</div>
</div>
<div class="form-group">
<label>Private Key (Encrypted with your passphrase)</label>
<textarea
readonly
rows="8"
value={generatedPrivateKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${displayUsername}-private-key.asc`)}>
Download (Required!)
</button>
</div>
</div>
<div class="key-storage-option">
<input
type="checkbox"
id="save-key-locally"
bind:checked={saveKeyLocally}
/>
<label for="save-key-locally">
Also save encrypted key in this browser for easier PGP login (you can export/remove it later)
</label>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<button on:click={saveGeneratedKeys} disabled={loading}>
Save Keys
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${displayUsername}-private-key.asc`)}>
Download
<button
class="btn btn-secondary"
on:click={() => { showGeneratedKeys = false; generatedPrivateKey = ''; generatedPublicKey = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}
>
Cancel
</button>
</div>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<button on:click={saveGeneratedKeys} disabled={loading}>
Save Keys
</button>
<button
class="btn btn-secondary"
on:click={() => { showGeneratedKeys = false; generatedPrivateKey = ''; generatedPublicKey = ''; }}
>
Cancel
</button>
</div>
{/if}
</div>
{:else}
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
@ -1435,4 +1623,42 @@ async function updateColor() {
</div>
</div>
</div>
{/if}
{#if showUnlockDialog}
<div class="warning-modal">
<div class="warning-content">
<h2>Unlock Private Key</h2>
<p style="margin-bottom: 1rem;">Enter your passphrase to decrypt and export your private key.</p>
{#if error}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
<div class="form-group">
<label for="unlock-passphrase">Passphrase</label>
<input
type="password"
id="unlock-passphrase"
bind:value={unlockPassphrase}
placeholder="Enter your key passphrase"
/>
</div>
<div class="warning-actions">
<button
class="btn btn-secondary"
on:click={() => { showUnlockDialog = false; unlockPassphrase = ''; }}
>
Cancel
</button>
<button
on:click={unlockAndExportKey}
disabled={!unlockPassphrase}
>
Unlock & Export
</button>
</div>
</div>
</div>
{/if}