2025-08-13 00:10:25 -04:00
|
|
|
// Client-side PGP utilities with encrypted storage
|
2025-08-03 21:53:15 -04:00
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
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) {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
throw new Error('PGP operations can only be performed in the browser');
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
// Validate passphrase
|
|
|
|
|
const passphraseError = validatePassphrase(passphrase);
|
|
|
|
|
if (passphraseError) {
|
|
|
|
|
throw new Error(passphraseError);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
const { generateKey, readKey } = await import('openpgp');
|
|
|
|
|
|
|
|
|
|
const { privateKey, publicKey } = await generateKey({
|
|
|
|
|
type: 'rsa',
|
|
|
|
|
rsaBits: 2048,
|
|
|
|
|
userIDs: [{ name: username }],
|
2025-08-13 00:10:25 -04:00
|
|
|
passphrase // Always encrypt with passphrase
|
2025-08-03 21:53:15 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const key = await readKey({ armoredKey: publicKey });
|
|
|
|
|
const fingerprint = key.getFingerprint();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
privateKey,
|
|
|
|
|
publicKey,
|
|
|
|
|
fingerprint
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getFingerprint(publicKey) {
|
|
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { readKey } = await import('openpgp');
|
|
|
|
|
const key = await readKey({ armoredKey: publicKey });
|
|
|
|
|
return key.getFingerprint();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error getting fingerprint:', error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
// 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) {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
throw new Error('PGP operations can only be performed in the browser');
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
if (!passphrase) {
|
|
|
|
|
throw new Error('Passphrase required to unlock private key');
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp');
|
|
|
|
|
|
|
|
|
|
const privateKey = await decryptKey({
|
|
|
|
|
privateKey: await readPrivateKey({ armoredKey: privateKeyArmored }),
|
|
|
|
|
passphrase
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const unsignedMessage = await createMessage({ text: message });
|
|
|
|
|
const signature = await sign({
|
|
|
|
|
message: unsignedMessage,
|
|
|
|
|
signingKeys: privateKey,
|
|
|
|
|
detached: true
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return signature;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function verifySignature(message, signature, publicKeyArmored) {
|
|
|
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { readKey, readSignature, createMessage, verify } = await import('openpgp');
|
|
|
|
|
|
|
|
|
|
const publicKey = await readKey({ armoredKey: publicKeyArmored });
|
|
|
|
|
const signatureObj = await readSignature({ armoredSignature: signature });
|
|
|
|
|
const messageObj = await createMessage({ text: message });
|
|
|
|
|
|
|
|
|
|
const verificationResult = await verify({
|
|
|
|
|
message: messageObj,
|
|
|
|
|
signature: signatureObj,
|
|
|
|
|
verificationKeys: publicKey
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { verified } = verificationResult.signatures[0];
|
|
|
|
|
return await verified;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Signature verification error:', error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-13 00:10:25 -04:00
|
|
|
// DEPRECATED - DO NOT USE
|
|
|
|
|
export function storePrivateKey() {
|
|
|
|
|
throw new Error('Plaintext key storage is disabled for security. Use saveEncryptedPrivateKey() instead.');
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getStoredPrivateKey() {
|
2025-08-13 00:10:25 -04:00
|
|
|
throw new Error('Plaintext key storage is disabled for security. Use unlockPrivateKey() instead.');
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function removeStoredPrivateKey() {
|
2025-08-13 00:10:25 -04:00
|
|
|
throw new Error('Plaintext key storage is disabled for security. Use removeEncryptedPrivateKey() instead.');
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|