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
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}