Malachite is a tool to import your Last.fm and Spotify listening history to the AT Protocol network using the fm.teal.alpha.feed.play lexicon.
malachite scrobbles importer atproto music
at development 215 lines 5.3 kB view raw
1import crypto from 'crypto'; 2import fs from 'fs'; 3import path from 'path'; 4import os from 'os'; 5 6/** 7 * Stored credentials structure 8 */ 9interface StoredCredentials { 10 version: number; 11 handle: string; 12 encryptedPassword: string; 13 iv: string; 14 salt: string; 15 createdAt: string; 16 lastUsedAt: string; 17} 18 19/** 20 * Get credentials file path 21 */ 22function getCredentialsPath(): string { 23 const credentialsDir = path.join(os.homedir(), '.malachite'); 24 if (!fs.existsSync(credentialsDir)) { 25 fs.mkdirSync(credentialsDir, { recursive: true }); 26 } 27 return path.join(credentialsDir, 'credentials.json'); 28} 29 30/** 31 * Derive encryption key from machine-specific data 32 * This makes credentials machine-specific and non-transferable 33 */ 34function deriveKey(salt: Buffer): Buffer { 35 // Use hostname and username to create a machine-specific key 36 const machineId = `${os.hostname()}-${os.userInfo().username}`; 37 38 // Use PBKDF2 to derive a strong key 39 return crypto.pbkdf2Sync( 40 machineId, 41 salt, 42 100000, // iterations 43 32, // key length 44 'sha256' 45 ); 46} 47 48/** 49 * Encrypt password 50 */ 51function encryptPassword(password: string, salt: Buffer): { encrypted: string; iv: string } { 52 const key = deriveKey(salt); 53 const iv = crypto.randomBytes(16); 54 55 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); 56 57 let encrypted = cipher.update(password, 'utf8', 'hex'); 58 encrypted += cipher.final('hex'); 59 60 // Append auth tag 61 const authTag = cipher.getAuthTag(); 62 encrypted += authTag.toString('hex'); 63 64 return { 65 encrypted, 66 iv: iv.toString('hex'), 67 }; 68} 69 70/** 71 * Decrypt password 72 */ 73function decryptPassword(encryptedData: string, iv: string, salt: Buffer): string { 74 const key = deriveKey(salt); 75 76 // Extract auth tag (last 32 hex characters = 16 bytes) 77 const authTag = Buffer.from(encryptedData.slice(-32), 'hex'); 78 const encrypted = encryptedData.slice(0, -32); 79 80 const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex')); 81 decipher.setAuthTag(authTag); 82 83 let decrypted = decipher.update(encrypted, 'hex', 'utf8'); 84 decrypted += decipher.final('utf8'); 85 86 return decrypted; 87} 88 89/** 90 * Save credentials to disk (encrypted) 91 */ 92export function saveCredentials(handle: string, password: string): void { 93 const credentialsPath = getCredentialsPath(); 94 95 // Generate random salt for this credential 96 const salt = crypto.randomBytes(32); 97 98 // Encrypt password 99 const { encrypted, iv } = encryptPassword(password, salt); 100 101 const credentials: StoredCredentials = { 102 version: 1, 103 handle, 104 encryptedPassword: encrypted, 105 iv, 106 salt: salt.toString('hex'), 107 createdAt: new Date().toISOString(), 108 lastUsedAt: new Date().toISOString(), 109 }; 110 111 fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8'); 112 113 // Set restrictive permissions (readable only by owner) 114 if (process.platform !== 'win32') { 115 fs.chmodSync(credentialsPath, 0o600); 116 } 117} 118 119/** 120 * Load credentials from disk (decrypted) 121 */ 122export function loadCredentials(): { handle: string; password: string } | null { 123 const credentialsPath = getCredentialsPath(); 124 125 if (!fs.existsSync(credentialsPath)) { 126 return null; 127 } 128 129 try { 130 const data = fs.readFileSync(credentialsPath, 'utf-8'); 131 const credentials: StoredCredentials = JSON.parse(data); 132 133 // Decrypt password 134 const salt = Buffer.from(credentials.salt, 'hex'); 135 const password = decryptPassword( 136 credentials.encryptedPassword, 137 credentials.iv, 138 salt 139 ); 140 141 // Update last used timestamp 142 credentials.lastUsedAt = new Date().toISOString(); 143 fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf-8'); 144 145 return { 146 handle: credentials.handle, 147 password, 148 }; 149 } catch (error) { 150 // If decryption fails or file is corrupted, return null 151 return null; 152 } 153} 154 155/** 156 * Check if credentials are saved 157 */ 158export function hasStoredCredentials(): boolean { 159 const credentialsPath = getCredentialsPath(); 160 return fs.existsSync(credentialsPath); 161} 162 163/** 164 * Get stored handle (without decrypting password) 165 */ 166export function getStoredHandle(): string | null { 167 const credentialsPath = getCredentialsPath(); 168 169 if (!fs.existsSync(credentialsPath)) { 170 return null; 171 } 172 173 try { 174 const data = fs.readFileSync(credentialsPath, 'utf-8'); 175 const credentials: StoredCredentials = JSON.parse(data); 176 return credentials.handle; 177 } catch { 178 return null; 179 } 180} 181 182/** 183 * Clear saved credentials 184 */ 185export function clearCredentials(): void { 186 const credentialsPath = getCredentialsPath(); 187 188 if (fs.existsSync(credentialsPath)) { 189 fs.unlinkSync(credentialsPath); 190 } 191} 192 193/** 194 * Get credentials info (for display purposes) 195 */ 196export function getCredentialsInfo(): { handle: string; createdAt: string; lastUsedAt: string } | null { 197 const credentialsPath = getCredentialsPath(); 198 199 if (!fs.existsSync(credentialsPath)) { 200 return null; 201 } 202 203 try { 204 const data = fs.readFileSync(credentialsPath, 'utf-8'); 205 const credentials: StoredCredentials = JSON.parse(data); 206 207 return { 208 handle: credentials.handle, 209 createdAt: credentials.createdAt, 210 lastUsedAt: credentials.lastUsedAt, 211 }; 212 } catch { 213 return null; 214 } 215}