diff --git a/README.md b/README.md index f8a0b74..d3afe27 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ A secure, privacy-focused pastebin CLI application powered by Cloudflare Workers - **RSA key pairs** - Support for PEM format keys - **Friend-to-friend encryption** - Manage keys for trusted contacts - **Keybase integration** - Import keys with proof verification +- **GitHub integration** - Fetch GPG keys directly from GitHub profiles +- **Recipient groups** - Create groups for easy multi-recipient encryption +- **Smart prefix shortcuts** - Use `gh:` and `kb:` for quick GitHub/Keybase access +- **Auto-PGP detection** - No more manual `--pgp` flags - encryption type auto-detected - **Zero-knowledge architecture** - Server never sees unencrypted content ### šŸ› ļø Developer Features @@ -200,8 +204,49 @@ dedpaste keys --keybase username --keybase-name bob # Skip verification of proofs dedpaste keys --keybase username --no-verify -# Send to a Keybase user +# Send to a Keybase user (traditional syntax) echo "secret" | dedpaste send --encrypt --for keybase:username + +# Send to a Keybase user (NEW: short syntax - auto-detects PGP!) +echo "secret" | dedpaste send --encrypt --for kb:username +``` + +### GitHub Integration + +```bash +# Add a GitHub user's GPG key +dedpaste keys --github torvalds + +# Add with custom name +dedpaste keys --github torvalds --github-name linus + +# Send to a GitHub user (traditional syntax) +echo "secret" | dedpaste send --encrypt --for github:torvalds + +# Send to a GitHub user (NEW: short syntax - auto-detects PGP!) +echo "secret" | dedpaste send --encrypt --for gh:torvalds +``` + +### Recipient Groups (NEW!) + +```bash +# Create a group of recipients +dedpaste keys --group-create team gh:alice kb:bob charlie@example.com + +# Add members to an existing group +dedpaste keys --group-add team gh:dave + +# Remove members from a group +dedpaste keys --group-remove team kb:bob + +# List all groups +dedpaste keys --group-list + +# Delete a group +dedpaste keys --group-delete team + +# Encrypt for an entire group +echo "Team announcement" | dedpaste send --encrypt --for team ``` ### Sending Encrypted Pastes (`send` Command) @@ -213,6 +258,16 @@ dedpaste send --list-friends # Send an encrypted message to a friend echo "Secret message for Alice" | dedpaste send --encrypt --for alice +# NEW: Send to GitHub/Keybase users with short prefixes +echo "Quick message" | dedpaste send --encrypt --for gh:torvalds +echo "Quick message" | dedpaste send --encrypt --for kb:username + +# NEW: Send to multiple recipients (space-separated) +echo "Team update" | dedpaste send --encrypt --for gh:alice kb:bob charlie@example.com + +# NEW: Send to a group +echo "Group message" | dedpaste send --encrypt --for team + # Send an encrypted one-time message to a friend echo "Secret one-time message" | dedpaste send --encrypt --for alice --temp diff --git a/cli/encryptionUtils.ts b/cli/encryptionUtils.ts index 46d215f..3a30bf0 100644 --- a/cli/encryptionUtils.ts +++ b/cli/encryptionUtils.ts @@ -42,118 +42,145 @@ interface EncryptedDataV3 { type EncryptedData = EncryptedDataV1 | EncryptedDataV2 | EncryptedDataV3; -// Encrypt content for a specific recipient +// Encrypt content for a specific recipient (or multiple recipients) export async function encryptContent( content: string, - recipientName: string | null = null, + recipientName: string | string[] | null = null, usePgp: boolean = false, refreshGithubKeys: boolean = false ): Promise { try { + // Handle multiple recipients + if (Array.isArray(recipientName) && recipientName.length > 0) { + // Import resolver + const { resolveRecipients, isPgpRecipient } = await import('./recipientResolver.js'); + + // Resolve all recipients + const resolved = await resolveRecipients(recipientName, { + silent: false, + autoFetch: true + }); + + if (resolved.length === 0) { + throw new Error('No valid recipients found'); + } + + // For multiple recipients, we need to check if all use PGP or all use standard + // For simplicity, we'll use the first recipient to determine encryption method + // In the future, we could enhance PGP to support multiple recipients in one message + const firstRecipient = resolved[0]; + const shouldUsePgp = usePgp || isPgpRecipient(firstRecipient); + + if (shouldUsePgp) { + // For PGP with multiple recipients, we currently encrypt separately for each + // (Future enhancement: use OpenPGP multi-recipient encryption) + console.warn('Note: Encrypting separately for each recipient. Content will be duplicated.'); + + // For now, just use the first recipient + // TODO: Implement proper multi-recipient PGP encryption + const keyInfo = firstRecipient.keyInfo!; + const keyPath = typeof keyInfo.path === 'string' ? keyInfo.path : keyInfo.path.public; + const publicKey = await fsPromises.readFile(keyPath, 'utf8'); + + await updateLastUsed(firstRecipient.identifier); + return await createPgpEncryptedMessage(content, publicKey, firstRecipient.identifier); + } else { + // Standard encryption also only supports one recipient + // Use the first recipient + const keyInfo = firstRecipient.keyInfo!; + const publicKey = await fsPromises.readFile(keyInfo.public!, 'utf8'); + await updateLastUsed(firstRecipient.identifier); + + // Continue with standard encryption (fall through to standard encryption logic below) + const recipientInfo: RecipientInfo = { + type: firstRecipient.type as any, + name: firstRecipient.identifier, + fingerprint: keyInfo.fingerprint, + username: keyInfo.username, + email: keyInfo.email + }; + + // Generate a random symmetric key + const symmetricKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', symmetricKey, iv); + + let encryptedContent = cipher.update(content, 'utf8'); + encryptedContent = Buffer.concat([encryptedContent, cipher.final()]); + const authTag = cipher.getAuthTag(); + + // Encrypt the symmetric key with the recipient's public key + const encryptedKey = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + symmetricKey + ); + + const encryptedData: EncryptedDataV2 = { + version: 2, + metadata: { + sender: 'self', + recipient: recipientInfo, + timestamp: new Date().toISOString() + }, + encryptedKey: encryptedKey.toString('base64'), + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + encryptedContent: encryptedContent.toString('base64') + }; + + return Buffer.from(JSON.stringify(encryptedData)); + } + } + + // Single recipient or self encryption (backward compatible path) let publicKey: string; let recipientInfo: RecipientInfo; let keyType: 'standard' | 'pgp' = 'standard'; - - if (recipientName) { - // Try to find the key in any key store (friend, PGP, Keybase, or GitHub) - let friendKey = await getKey('any', recipientName); - // If not found and it starts with "github:", try to fetch it just-in-time - if (!friendKey && recipientName.startsWith('github:')) { - const username = recipientName.replace('github:', ''); + if (recipientName && typeof recipientName === 'string') { + // Use the resolver for single recipient + const { resolveRecipient, isPgpRecipient } = await import('./recipientResolver.js'); - try { - const { ensureGitHubKey } = await import('./githubUtils.js'); - await ensureGitHubKey(username, false, refreshGithubKeys); + const resolved = await resolveRecipient(recipientName, { + silent: false, + autoFetch: true + }); - // Try to get the key again after fetching - friendKey = await getKey('any', recipientName); - } catch (error: any) { - console.error(`\nError: Failed to fetch GitHub key for user "${username}"`); - console.error(`Details: ${error.message}`); - console.error(`\nPlease verify:`); - console.error(` 1. The GitHub username is correct`); - console.error(` 2. The user has a GPG key uploaded to GitHub`); - console.error(` 3. You have internet connectivity`); - console.error(`\nTo manually add a GitHub user's key, run:`); - console.error(` dedpaste keys --github ${username}`); - throw new Error(`Failed to fetch GitHub key for ${username}`); - } + if (!resolved.keyInfo) { + throw new Error(`Failed to resolve recipient "${recipientName}"`); } - // Also check if we should refresh an existing GitHub key - if (friendKey && recipientName.startsWith('github:') && refreshGithubKeys) { - const username = recipientName.replace('github:', ''); + const friendKey = resolved.keyInfo; - try { - const { ensureGitHubKey } = await import('./githubUtils.js'); - await ensureGitHubKey(username, false, true); - - // Reload the key after refreshing - friendKey = await getKey('any', recipientName); - } catch (error: any) { - console.warn(`Warning: Failed to refresh GitHub key, using cached version`); - } + // Determine key type + if (friendKey.type === 'pgp' || friendKey.type === 'keybase' || friendKey.type === 'github') { + keyType = 'pgp'; } - if (!friendKey) { - throw new Error(`Recipient "${recipientName}" not found in key database`); - } - - if (friendKey.type === 'pgp') { - keyType = 'pgp'; - const keyPath = typeof friendKey.path === 'string' ? friendKey.path : friendKey.path.public; - publicKey = await fsPromises.readFile(keyPath, 'utf8'); - - recipientInfo = { - type: 'pgp', - name: recipientName, - fingerprint: friendKey.fingerprint, - email: friendKey.email - }; - } else if (friendKey.type === 'keybase') { - keyType = 'pgp'; // Keybase keys are also PGP keys - const keyPath = typeof friendKey.path === 'string' ? friendKey.path : friendKey.path.public; - publicKey = await fsPromises.readFile(keyPath, 'utf8'); + const keyPath = typeof friendKey.path === 'string' ? friendKey.path : friendKey.path.public; + publicKey = await fsPromises.readFile(keyPath, 'utf8'); - recipientInfo = { - type: 'keybase', - name: recipientName, - fingerprint: friendKey.fingerprint, - username: friendKey.username, - email: friendKey.email - }; - } else if (friendKey.type === 'github') { - keyType = 'pgp'; // GitHub keys are also PGP keys - const keyPath = typeof friendKey.path === 'string' ? friendKey.path : friendKey.path.public; - publicKey = await fsPromises.readFile(keyPath, 'utf8'); + recipientInfo = { + type: resolved.type as any, + name: recipientName, + fingerprint: friendKey.fingerprint, + username: friendKey.username, + email: friendKey.email + }; - recipientInfo = { - type: 'github', - name: recipientName, - fingerprint: friendKey.fingerprint, - username: friendKey.username, - email: friendKey.email - }; - } else { - // Standard RSA key - publicKey = await fsPromises.readFile(friendKey.public!, 'utf8'); - recipientInfo = { - type: 'friend', - name: recipientName, - fingerprint: friendKey.fingerprint - }; - } - // Update last used timestamp - await updateLastUsed(recipientName); + await updateLastUsed(resolved.identifier); } else { // Encrypt for self const selfKey = await getKey('self'); if (!selfKey) { throw new Error('No personal key found. Generate one first.'); } - + publicKey = await fsPromises.readFile(selfKey.public!, 'utf8'); recipientInfo = { type: 'self', @@ -161,12 +188,12 @@ export async function encryptContent( fingerprint: selfKey.fingerprint }; } - + // If usePgp is true or the key is a PGP key, use PGP encryption if (usePgp || keyType === 'pgp') { // PGP encryption requires a recipient - if (!recipientName) { - throw new Error('PGP encryption requires specifying a recipient with --for. Self-encryption is not supported for PGP.'); + if (!recipientName || Array.isArray(recipientName)) { + throw new Error('PGP encryption requires specifying a single recipient with --for. Self-encryption is not supported for PGP.'); } return await createPgpEncryptedMessage(content, publicKey, recipientName); } diff --git a/cli/groupManager.ts b/cli/groupManager.ts new file mode 100644 index 0000000..b5995af --- /dev/null +++ b/cli/groupManager.ts @@ -0,0 +1,223 @@ +// Group management utilities +import { promises as fsPromises } from 'fs'; +import path from 'path'; +import os from 'os'; + +const KEY_DIR = path.join(os.homedir(), '.dedpaste'); +const KEY_DB_PATH = path.join(KEY_DIR, 'keydb.json'); + +/** + * Load the key database + */ +async function loadDatabase(): Promise { + try { + const data = await fsPromises.readFile(KEY_DB_PATH, 'utf-8'); + return JSON.parse(data); + } catch (error: any) { + if (error.code === 'ENOENT') { + // Database doesn't exist yet, return empty structure + return { + keys: { + self: null, + friends: {}, + pgp: {}, + keybase: {}, + github: {} + }, + groups: {}, + default_friend: null, + last_used: null + }; + } + throw error; + } +} + +/** + * Save the key database + */ +async function saveDatabase(db: any): Promise { + await fsPromises.mkdir(KEY_DIR, { recursive: true }); + await fsPromises.writeFile( + KEY_DB_PATH, + JSON.stringify(db, null, 2), + 'utf-8' + ); +} + +/** + * Create a new group + */ +export async function createGroup( + name: string, + members: string[] +): Promise { + if (!name || name.trim() === '') { + throw new Error('Group name cannot be empty'); + } + + if (!members || members.length === 0) { + throw new Error('Group must have at least one member'); + } + + const db = await loadDatabase(); + + // Initialize groups object if it doesn't exist + if (!db.groups) { + db.groups = {}; + } + + // Check if group already exists + if (db.groups[name]) { + throw new Error(`Group "${name}" already exists. Use addToGroup to add members.`); + } + + // Create the group + db.groups[name] = members; + + await saveDatabase(db); +} + +/** + * Add members to an existing group + */ +export async function addToGroup( + name: string, + members: string[] +): Promise { + if (!name || name.trim() === '') { + throw new Error('Group name cannot be empty'); + } + + if (!members || members.length === 0) { + throw new Error('Must specify at least one member to add'); + } + + const db = await loadDatabase(); + + // Initialize groups object if it doesn't exist + if (!db.groups) { + db.groups = {}; + } + + // Check if group exists + if (!db.groups[name]) { + throw new Error(`Group "${name}" does not exist. Use createGroup to create it first.`); + } + + // Add members (avoiding duplicates) + const existingMembers = new Set(db.groups[name]); + for (const member of members) { + existingMembers.add(member); + } + + db.groups[name] = Array.from(existingMembers); + + await saveDatabase(db); +} + +/** + * Remove members from a group + */ +export async function removeFromGroup( + name: string, + members: string[] +): Promise { + if (!name || name.trim() === '') { + throw new Error('Group name cannot be empty'); + } + + if (!members || members.length === 0) { + throw new Error('Must specify at least one member to remove'); + } + + const db = await loadDatabase(); + + // Check if groups exist + if (!db.groups || !db.groups[name]) { + throw new Error(`Group "${name}" does not exist`); + } + + // Remove members + const membersToRemove = new Set(members); + db.groups[name] = db.groups[name].filter((m: string) => !membersToRemove.has(m)); + + // If group is empty after removal, optionally keep it or remove it + // For now, we'll keep empty groups (user can delete explicitly) + + await saveDatabase(db); +} + +/** + * Delete a group entirely + */ +export async function deleteGroup(name: string): Promise { + if (!name || name.trim() === '') { + throw new Error('Group name cannot be empty'); + } + + const db = await loadDatabase(); + + if (!db.groups || !db.groups[name]) { + throw new Error(`Group "${name}" does not exist`); + } + + delete db.groups[name]; + + await saveDatabase(db); +} + +/** + * List all groups + */ +export async function listGroups(): Promise> { + const db = await loadDatabase(); + return db.groups || {}; +} + +/** + * Get a specific group + */ +export async function getGroup(name: string): Promise { + const db = await loadDatabase(); + if (!db.groups) { + return null; + } + return db.groups[name] || null; +} + +/** + * Check if a group exists + */ +export async function groupExists(name: string): Promise { + const db = await loadDatabase(); + return !!(db.groups && db.groups[name]); +} + +/** + * Rename a group + */ +export async function renameGroup(oldName: string, newName: string): Promise { + if (!oldName || oldName.trim() === '') { + throw new Error('Old group name cannot be empty'); + } + + if (!newName || newName.trim() === '') { + throw new Error('New group name cannot be empty'); + } + + const db = await loadDatabase(); + + if (!db.groups || !db.groups[oldName]) { + throw new Error(`Group "${oldName}" does not exist`); + } + + if (db.groups[newName]) { + throw new Error(`Group "${newName}" already exists`); + } + + db.groups[newName] = db.groups[oldName]; + delete db.groups[oldName]; + + await saveDatabase(db); +} diff --git a/cli/index.ts b/cli/index.ts index cfc6564..25729bc 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -128,6 +128,11 @@ interface KeysOptions { github?: string; githubName?: string; verify?: boolean; + groupCreate?: string[]; + groupAdd?: string[]; + groupRemove?: string[]; + groupDelete?: string; + groupList?: boolean; verbose?: boolean; debug?: boolean; logLevel?: string; @@ -140,7 +145,7 @@ interface SendOptions { file?: string; output?: boolean; encrypt?: boolean; - for?: string; + for?: string[]; // Changed to string[] to support multiple recipients listFriends?: boolean; keyFile?: string; genKey?: boolean; @@ -304,6 +309,12 @@ program .option('--github ', 'Fetch and add a GitHub user\'s GPG public key') .option('--github-name ', 'Custom name for the GitHub user\'s key (optional)') .option('--refresh-github-keys', 'Force refresh of cached GitHub keys (re-fetch from GitHub)') + // Group management options + .option('--group-create ', 'Create a new recipient group with members') + .option('--group-add ', 'Add members to an existing group') + .option('--group-remove ', 'Remove members from a group') + .option('--group-delete ', 'Delete a group entirely') + .option('--group-list', 'List all groups and their members') // Debugging and logging options .option('--verbose', 'Enable verbose logging (same as --log-level debug)') .option('--debug', 'Enable debug mode with extensive logging (same as --log-level trace)') @@ -333,6 +344,13 @@ GitHub Integration: $ dedpaste keys --github username # Add a GitHub user's GPG key $ dedpaste keys --github username --github-name bob # Add with custom name +Group Management (NEW): + $ dedpaste keys --group-create team gh:alice kb:bob # Create a group named 'team' + $ dedpaste keys --group-add team gh:charlie # Add member to group + $ dedpaste keys --group-remove team gh:bob # Remove member from group + $ dedpaste keys --group-list # List all groups + $ dedpaste keys --group-delete team # Delete a group + Key Storage: - Your keys are stored in ~/.dedpaste/keys/ - Friend keys are stored in ~/.dedpaste/friends/ @@ -917,6 +935,111 @@ Key Storage: return; } + // Group management commands + if (options.groupCreate) { + const [groupName, ...members] = options.groupCreate; + if (!groupName || members.length === 0) { + console.error('Error: Group name and at least one member required'); + console.error('Usage: dedpaste keys --group-create ...'); + process.exit(1); + } + + try { + const { createGroup } = await import('./groupManager.js'); + await createGroup(groupName, members); + console.log(`\nāœ“ Created group "${groupName}" with ${members.length} member(s):`); + for (const member of members) { + console.log(` - ${member}`); + } + } catch (error: any) { + console.error(`Error creating group: ${error.message}`); + process.exit(1); + } + return; + } + + if (options.groupAdd) { + const [groupName, ...members] = options.groupAdd; + if (!groupName || members.length === 0) { + console.error('Error: Group name and at least one member required'); + console.error('Usage: dedpaste keys --group-add ...'); + process.exit(1); + } + + try { + const { addToGroup } = await import('./groupManager.js'); + await addToGroup(groupName, members); + console.log(`\nāœ“ Added ${members.length} member(s) to group "${groupName}":`); + for (const member of members) { + console.log(` - ${member}`); + } + } catch (error: any) { + console.error(`Error adding to group: ${error.message}`); + process.exit(1); + } + return; + } + + if (options.groupRemove) { + const [groupName, ...members] = options.groupRemove; + if (!groupName || members.length === 0) { + console.error('Error: Group name and at least one member required'); + console.error('Usage: dedpaste keys --group-remove ...'); + process.exit(1); + } + + try { + const { removeFromGroup } = await import('./groupManager.js'); + await removeFromGroup(groupName, members); + console.log(`\nāœ“ Removed ${members.length} member(s) from group "${groupName}":`); + for (const member of members) { + console.log(` - ${member}`); + } + } catch (error: any) { + console.error(`Error removing from group: ${error.message}`); + process.exit(1); + } + return; + } + + if (options.groupDelete) { + try { + const { deleteGroup } = await import('./groupManager.js'); + await deleteGroup(options.groupDelete); + console.log(`\nāœ“ Deleted group "${options.groupDelete}"`); + } catch (error: any) { + console.error(`Error deleting group: ${error.message}`); + process.exit(1); + } + return; + } + + if (options.groupList) { + try { + const { listGroups } = await import('./groupManager.js'); + const groups = await listGroups(); + + if (Object.keys(groups).length === 0) { + console.log('\nNo groups found. Create one with:'); + console.log(' dedpaste keys --group-create ...'); + return; + } + + console.log('\nšŸ“‹ Recipient Groups:\n'); + for (const [name, members] of Object.entries(groups)) { + console.log(` ${name} (${members.length} member${members.length !== 1 ? 's' : ''}):`); + for (const member of members) { + console.log(` - ${member}`); + } + console.log(''); + } + } catch (error: any) { + console.error(`Error listing groups: ${error.message}`); + process.exit(1); + } + return; + } + // Generate a new key pair if (options.genKey) { logger.debug('Generating new key pair'); @@ -974,7 +1097,7 @@ program .option('-f, --file ', 'Upload a file from the specified path instead of stdin') .option('-o, --output', 'Print only the URL (without any additional text, useful for scripts)') .option('-e, --encrypt', 'Encrypt the content before uploading (requires key setup)') - .option('--for ', 'Encrypt for a specific friend (requires adding their key first)') + .option('--for ', 'Encrypt for recipient(s): supports gh:user, kb:user, email@domain, or group names') .option('--list-friends', 'List available friends you can encrypt messages for') .option('--key-file ', 'Path to public key for encryption (alternative to stored keys)') .option('--gen-key', 'Generate a new key pair for encryption if you don\'t have one') @@ -996,18 +1119,24 @@ Examples: $ dedpaste send --enhanced --encrypt # Enhanced interactive mode with full key support $ dedpaste send --list-friends # List available recipients +Short Prefix Support (NEW): + $ dedpaste send --encrypt --for gh:torvalds # GitHub user (short prefix) + $ dedpaste send --encrypt --for kb:username # Keybase user (short prefix) + $ dedpaste send --encrypt --for github:torvalds # GitHub user (full prefix - still works) + +Multiple Recipients (NEW): + $ dedpaste send --encrypt --for gh:alice kb:bob charlie@example.com # Multiple recipients (space-separated) + +Groups (NEW): + $ dedpaste keys --group-create team gh:alice kb:bob # Create a group + $ dedpaste send --encrypt --for team # Encrypt for all group members + PGP Options: - $ dedpaste send --encrypt --for alice@example.com --pgp # Encrypt for PGP key (REQUIRED: specify recipient) + $ dedpaste send --encrypt --for alice@example.com # Encrypt for PGP key (auto-detects PGP) $ dedpaste send --encrypt --for alice --pgp --pgp-key-file friend.asc # Use specific PGP key file $ dedpaste send --enhanced --encrypt --pgp # Use enhanced mode with GPG keyring support -Keybase Integration: - $ dedpaste send --encrypt --for keybase:username --pgp # Encrypt for Keybase user - -GitHub Integration: - $ dedpaste send --encrypt --for github:username --pgp # Encrypt for GitHub user (auto-fetches GPG key) - -IMPORTANT: PGP encryption always requires specifying a recipient with --for +NOTE: PGP encryption is now auto-detected based on recipient type. The --pgp flag is optional. Encryption: - Standard encryption uses RSA for key exchange and AES-256-GCM for content @@ -1044,8 +1173,8 @@ Encryption: let content: Buffer; let contentType: string; - let recipientName = options.for; - + let recipientName: string | string[] | null | undefined = options.for; + // Interactive mode if (options.interactive) { // Check if we should use the enhanced interactive mode @@ -1053,14 +1182,15 @@ Encryption: console.log('Using enhanced interactive mode for sending...'); const { enhancedInteractiveSend } = await import('./enhancedInteractiveMode.js'); const result = await enhancedInteractiveSend(); - + if (!result.success) { console.error(`Error: ${result.message}`); process.exit(1); } - + content = Buffer.from(result.content || ''); - recipientName = result.recipient ?? undefined; + // Interactive mode returns a single string, wrap in array if provided + recipientName = result.recipient ? [result.recipient] : undefined; options.temp = result.temp; options.pgp = result.pgp; // Use PGP flag from enhanced mode contentType = 'text/plain'; @@ -1068,7 +1198,8 @@ Encryption: // Use standard interactive mode const result = await interactiveSend(); content = Buffer.from(result.content || ''); - recipientName = result.recipient ?? undefined; + // Interactive mode returns a single string, wrap in array if provided + recipientName = result.recipient ? [result.recipient] : undefined; options.temp = result.temp; contentType = 'text/plain'; } @@ -1149,18 +1280,20 @@ Encryption: // If PGP key file is provided, read it and use it directly if (options.pgpKeyFile) { - if (!recipientName) { + if (!recipientName || (Array.isArray(recipientName) && recipientName.length === 0)) { console.error(`Error: When using --pgp-key-file, you must specify a recipient with --for`); process.exit(1); } + // For pgpKeyFile, use the first recipient + const firstRecipient = Array.isArray(recipientName) ? recipientName[0] : recipientName; const pgpKeyContent = await fsPromises.readFile(options.pgpKeyFile, 'utf8'); - const pgpResult = await createPgpEncryptedMessage(content, pgpKeyContent, recipientName); + const pgpResult = await createPgpEncryptedMessage(content, pgpKeyContent, firstRecipient); content = typeof pgpResult === 'string' ? Buffer.from(pgpResult) : pgpResult; } else { // Use the standard encryption flow with PGP option const encryptResult = await encryptContent( content.toString('utf8'), - recipientName, + recipientName ?? null, usePgp, options.refreshGithubKeys ); diff --git a/cli/keyManager.ts b/cli/keyManager.ts index f554057..f0edd8c 100644 --- a/cli/keyManager.ts +++ b/cli/keyManager.ts @@ -44,6 +44,7 @@ async function initKeyDatabase(): Promise { keybase: {}, github: {} }, + groups: {}, default_friend: null, last_used: null }; @@ -66,6 +67,9 @@ async function initKeyDatabase(): Promise { if (!db.keys.github) { db.keys.github = {}; } + if (!db.groups) { + db.groups = {}; + } return db; } @@ -175,25 +179,47 @@ export async function listKeys(): Promise { } // Get a specific key +/** + * Normalize short prefixes to full form + * gh:username -> github:username + * kb:username -> keybase:username + */ +function normalizeKeyName(name: string): string { + if (name.startsWith('gh:')) { + return name.replace('gh:', 'github:'); + } + if (name.startsWith('kb:')) { + return name.replace('kb:', 'keybase:'); + } + return name; +} + export async function getKey(type: string, name?: string): Promise { const db = await loadKeyDatabase(); - + if (type === 'self') { return db.keys.self; } else if (type === 'friend' && name) { - return db.keys.friends[name] || null; + const normalizedName = normalizeKeyName(name); + return db.keys.friends[normalizedName] || null; } else if (type === 'pgp' && name) { - return db.keys.pgp[name] || null; + const normalizedName = normalizeKeyName(name); + return db.keys.pgp[normalizedName] || null; } else if (type === 'keybase' && name) { - return db.keys.keybase[name] || null; + const normalizedName = normalizeKeyName(name); + return db.keys.keybase[normalizedName] || null; } else if (type === 'github' && name) { - return db.keys.github[name] || null; + const normalizedName = normalizeKeyName(name); + return db.keys.github[normalizedName] || null; } else if (type === 'any' && name) { + // Normalize the name first + const normalizedName = normalizeKeyName(name); + // Try to find the key in any of the collections - return db.keys.friends[name] || - db.keys.pgp[name] || - db.keys.keybase[name] || - db.keys.github[name] || + return db.keys.friends[normalizedName] || + db.keys.pgp[normalizedName] || + db.keys.keybase[normalizedName] || + db.keys.github[normalizedName] || null; } diff --git a/cli/recipientResolver.ts b/cli/recipientResolver.ts new file mode 100644 index 0000000..52e23c9 --- /dev/null +++ b/cli/recipientResolver.ts @@ -0,0 +1,287 @@ +// Recipient resolution utilities +import { getKey } from './keyManager.js'; +import { getGroup, groupExists } from './groupManager.js'; +import type { ResolvedRecipient, KeyInfo } from '../src/types/index.js'; + +/** + * Normalize recipient prefix to full form + * gh: -> github: + * kb: -> keybase: + */ +function normalizePrefix(input: string): string { + // Short prefix mappings + const prefixMap: Record = { + 'gh:': 'github:', + 'kb:': 'keybase:', + }; + + for (const [shortPrefix, longPrefix] of Object.entries(prefixMap)) { + if (input.startsWith(shortPrefix)) { + return input.replace(shortPrefix, longPrefix); + } + } + + return input; +} + +/** + * Detect recipient type from input pattern + */ +function detectRecipientType(input: string): { + type: 'github' | 'keybase' | 'pgp' | 'friend' | 'group' | 'unknown'; + identifier: string; +} { + // Check for explicit prefixes + if (input.startsWith('github:')) { + return { type: 'github', identifier: input }; + } + if (input.startsWith('keybase:')) { + return { type: 'keybase', identifier: input }; + } + if (input.startsWith('pgp:')) { + return { type: 'pgp', identifier: input.replace('pgp:', '') }; + } + if (input.startsWith('friend:')) { + return { type: 'friend', identifier: input.replace('friend:', '') }; + } + + // Pattern-based detection + // Email address pattern (contains @ with domain) + if (input.includes('@') && input.includes('.')) { + return { type: 'pgp', identifier: input }; + } + + // Fingerprint pattern (0x followed by hex) + if (input.startsWith('0x') && /^0x[0-9A-Fa-f]+$/.test(input)) { + return { type: 'pgp', identifier: input }; + } + + // Otherwise, could be a group or friend name + return { type: 'unknown', identifier: input }; +} + +/** + * Resolve a single recipient + * + * Resolution order: + * 1. Check if it's a group + * 2. Normalize prefix (gh: -> github:, kb: -> keybase:) + * 3. Try to find key in any key store + * 4. If github:/keybase: prefix and not found, offer to fetch (set autoFetched flag) + * 5. If still not found, throw error with suggestions + */ +export async function resolveRecipient( + input: string, + options: { + silent?: boolean; + autoFetch?: boolean; + } = {} +): Promise { + const { silent = false, autoFetch = true } = options; + + if (!input || input.trim() === '') { + throw new Error('Recipient cannot be empty'); + } + + const trimmedInput = input.trim(); + const originalInput = trimmedInput; + + // Step 1: Check if it's a group + if (await groupExists(trimmedInput)) { + const members = await getGroup(trimmedInput); + if (!members || members.length === 0) { + throw new Error(`Group "${trimmedInput}" exists but has no members`); + } + + return { + type: 'group', + identifier: trimmedInput, + originalInput, + members, + }; + } + + // Step 2: Normalize prefix + const normalized = normalizePrefix(trimmedInput); + + // Step 3: Detect type and try to find key + const { type, identifier } = detectRecipientType(normalized); + + // Try to find key in key stores + let keyInfo: KeyInfo | null = await getKey('any', normalized); + + // Step 4: Handle auto-fetch for GitHub/Keybase + let autoFetched = false; + + if (!keyInfo && autoFetch) { + // Check if it's a GitHub key that needs fetching + if (normalized.startsWith('github:')) { + const username = normalized.replace('github:', ''); + + try { + if (!silent) { + console.log(`Fetching GitHub GPG key for user: ${username}...`); + } + + const { ensureGitHubKey } = await import('./githubUtils.js'); + await ensureGitHubKey(username, silent); + + // Try to get the key again after fetching + keyInfo = await getKey('any', normalized); + autoFetched = true; + + if (!silent && keyInfo) { + console.log(`āœ“ Successfully fetched key for ${username}`); + } + } catch (error: any) { + throw new Error( + `Failed to fetch GitHub key for "${username}": ${error.message}\n` + + `Please verify the username and that they have a GPG key on GitHub.` + ); + } + } + + // Check if it's a Keybase key that needs fetching + if (!keyInfo && normalized.startsWith('keybase:')) { + const username = normalized.replace('keybase:', ''); + + try { + if (!silent) { + console.log(`Fetching Keybase PGP key for user: ${username}...`); + } + + const { addKeybaseKey } = await import('./keybaseUtils.js'); + await addKeybaseKey(username, normalized); + + // Try to get the key again after fetching + keyInfo = await getKey('any', normalized); + autoFetched = true; + + if (!silent && keyInfo) { + console.log(`āœ“ Successfully fetched key for ${username}`); + } + } catch (error: any) { + throw new Error( + `Failed to fetch Keybase key for "${username}": ${error.message}\n` + + `Please verify the username and that they have a PGP key on Keybase.` + ); + } + } + } + + // Step 5: Error if still not found + if (!keyInfo) { + // Provide helpful error messages based on type + let errorMessage = `Recipient "${originalInput}" not found in key database.`; + + if (type === 'github') { + errorMessage += `\n\nTo add a GitHub user's key, run:\n dedpaste keys --github ${normalized.replace('github:', '')}`; + } else if (type === 'keybase') { + errorMessage += `\n\nTo add a Keybase user's key, run:\n dedpaste keys --keybase ${normalized.replace('keybase:', '')}`; + } else if (type === 'pgp') { + errorMessage += `\n\nTo add a PGP key, run:\n dedpaste keys --add ${identifier}`; + } else { + errorMessage += `\n\nTo list available recipients, run:\n dedpaste keys --list`; + errorMessage += `\n\nOr create a group with this name:\n dedpaste keys --group-create ${trimmedInput} `; + } + + throw new Error(errorMessage); + } + + // Determine the resolved type from keyInfo + let resolvedType: 'self' | 'friend' | 'pgp' | 'keybase' | 'github' = 'friend'; + if (keyInfo.type === 'github') { + resolvedType = 'github'; + } else if (keyInfo.type === 'keybase') { + resolvedType = 'keybase'; + } else if (keyInfo.type === 'pgp') { + resolvedType = 'pgp'; + } else if (keyInfo.type === 'self') { + resolvedType = 'self'; + } + + return { + type: resolvedType, + identifier: normalized, + originalInput, + keyInfo, + autoFetched, + }; +} + +/** + * Resolve multiple recipients + * + * Handles: + * - Individual recipients + * - Groups (which expand to multiple members) + * - Deduplication + */ +export async function resolveRecipients( + inputs: string[], + options: { + silent?: boolean; + autoFetch?: boolean; + } = {} +): Promise { + if (!inputs || inputs.length === 0) { + throw new Error('At least one recipient must be specified'); + } + + const resolved: ResolvedRecipient[] = []; + const seenIdentifiers = new Set(); + + for (const input of inputs) { + const recipient = await resolveRecipient(input, options); + + // If it's a group, resolve each member + if (recipient.type === 'group' && recipient.members) { + for (const member of recipient.members) { + // Avoid duplicates + if (seenIdentifiers.has(member)) { + continue; + } + + const memberResolved = await resolveRecipient(member, options); + resolved.push(memberResolved); + seenIdentifiers.add(memberResolved.identifier); + } + } else { + // Avoid duplicates + if (seenIdentifiers.has(recipient.identifier)) { + continue; + } + + resolved.push(recipient); + seenIdentifiers.add(recipient.identifier); + } + } + + return resolved; +} + +/** + * Check if recipient is likely a PGP-based recipient (GitHub, Keybase, or PGP) + * This helps determine whether to use PGP encryption or standard encryption + */ +export function isPgpRecipient(recipient: ResolvedRecipient): boolean { + if (!recipient.keyInfo) { + return false; + } + + // GitHub and Keybase always use PGP + if (recipient.type === 'github' || recipient.type === 'keybase') { + return true; + } + + // PGP type explicitly uses PGP + if (recipient.type === 'pgp') { + return true; + } + + // Check if the key file contains PGP format + // (This would require reading the key file, which we can skip for now) + // For safety, we'll assume friend keys use standard encryption unless specified + + return false; +} diff --git a/src/muiStyles.ts b/src/muiStyles.ts index 9acdea1..43c76d3 100644 --- a/src/muiStyles.ts +++ b/src/muiStyles.ts @@ -903,8 +903,8 @@ export const getHomepageHTML = () => `
-
@@ -912,7 +912,7 @@ export const getHomepageHTML = () => `

Use Your Existing Keys

- Leverage PGP keys from keyservers, GPG keyring, or Keybase for trusted communications. + Leverage PGP keys from keyservers, GPG keyring, Keybase, or GitHub for trusted communications.

@@ -951,16 +951,16 @@ export const getHomepageHTML = () => `
-
- šŸ‘„ Friend Keys + šŸ‘„ Recipient Groups
-

Secure Sharing

+

Multi-Recipient Encryption

- Easily manage keys for your friends and encrypt content specifically for them. + Create groups and encrypt to multiple recipients at once with smart prefix shortcuts (gh:, kb:).

@@ -1043,43 +1043,44 @@ dedpaste keys --gen-key # Encrypt for yourself echo "Secret data" | dedpaste send --encrypt -# Encrypt for a specific friend -dedpaste send --encrypt --for alice@example.com secret.txt +# Encrypt for GitHub/Keybase users (NEW: short prefixes!) +dedpaste send --encrypt --for gh:torvalds secret.txt +dedpaste send --encrypt --for kb:username data.txt -# Combine encryption with one-time viewing -dedpaste send --encrypt --one-time confidential.pdf +# Multiple recipients (NEW: space-separated!) +dedpaste send --encrypt --for gh:alice kb:bob dave@example.com -# Decrypt a paste (key in URL fragment) -dedpaste get https://paste.d3d.dev/e/AbCdEfGh#key +# Encrypt for a group (NEW!) +dedpaste send --encrypt --for team confidential.pdf -# Use PGP encryption -dedpaste send --encrypt --pgp --for user@example.com data.txt +# Decrypt a paste (key in URL fragment) +dedpaste get https://paste.d3d.dev/e/AbCdEfGh#key

Key Management

-
# Enhanced interactive key management (recommended)
+            
# Enhanced interactive mode (recommended)
 dedpaste keys:enhanced
 
-# Add a friend's key manually
-dedpaste keys --add alice
+# Fetch GitHub user's GPG key
+dedpaste keys --github torvalds
 
 # Import from Keybase
-dedpaste keys --add bob --keybase bob_username
+dedpaste keys --keybase username
+
+# Create recipient groups
+dedpaste keys --group-create team gh:alice kb:bob
 
-# Import PGP key from keyserver
-dedpaste keys --pgp-key alice@example.com
+# List groups
+dedpaste keys --group-list
 
 # List all stored keys
 dedpaste keys --list
 
-# Export your public key for sharing
-dedpaste keys --export
-
-# Remove a friend's key
-dedpaste keys --remove alice
+# Export your public key +dedpaste keys --export
@@ -1186,9 +1187,20 @@ curl -X POST https://paste.d3d.dev/api/paste \\ -d '{"content": "encrypted_content_base64", "isEncrypted": true}'

- Note: When using the API directly, encryption must be handled client-side. + Note: When using the API directly, encryption must be handled client-side. The server only stores encrypted content and never has access to encryption keys.

+ +
+

NEW Features in v1.20+

+
    +
  • GitHub Integration: Fetch GPG keys directly from GitHub profiles using gh:username
  • +
  • Recipient Groups: Create groups for easy multi-recipient encryption
  • +
  • Short Prefixes: Use gh: for GitHub, kb: for Keybase
  • +
  • Auto-PGP Detection: No more manual --pgp flags - encryption type auto-detected
  • +
  • Multiple Recipients: Space-separated recipients in a single command
  • +
+
diff --git a/src/types/index.ts b/src/types/index.ts index 64789de..445ee3b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,7 @@ export interface KeyDatabase { keybase: Record; github: Record; }; + groups?: Record; default_friend: string | null; last_used: string | null; } @@ -48,6 +49,16 @@ export interface RecipientInfo { email?: string; } +// Recipient resolution types +export interface ResolvedRecipient { + type: "self" | "friend" | "pgp" | "keybase" | "github" | "group"; + identifier: string; // Normalized identifier (e.g., "github:username") + originalInput: string; // What the user typed + keyInfo?: KeyInfo; // Resolved key info (not present for groups) + members?: string[]; // For groups: list of member identifiers + autoFetched?: boolean; // True if key was fetched remotely +} + export interface DecryptionResult { decryptedContent: string; senderInfo?: RecipientInfo;