@bostonuniversity/buwp-local 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.js CHANGED
@@ -7,6 +7,7 @@ import fs from 'fs';
7
7
  import path from 'path';
8
8
  import chalk from 'chalk';
9
9
  import dotenv from 'dotenv';
10
+ import * as keychain from './keychain.js';
10
11
 
11
12
  const CONFIG_FILE_NAME = '.buwp-local.json';
12
13
  const ENV_FILE_NAME = '.env.local';
@@ -283,6 +284,96 @@ function sanitizeProjectName(name) {
283
284
  .replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes
284
285
  }
285
286
 
287
+ /**
288
+ * Load credentials from macOS keychain
289
+ * Returns all 15 credentials if available, with platform and keychain support checks
290
+ * @returns {object} Credentials object or empty object if not supported
291
+ */
292
+ export function loadKeychainCredentials() {
293
+ // Only load on macOS where keychain is supported
294
+ if (!keychain.isPlatformSupported()) {
295
+ return {};
296
+ }
297
+
298
+ const credentials = {};
299
+
300
+ // Try to load each known credential from keychain
301
+ for (const key of keychain.CREDENTIAL_KEYS) {
302
+ const value = keychain.getCredential(key);
303
+ if (value !== null) {
304
+ credentials[key] = value;
305
+ }
306
+ }
307
+
308
+ return credentials;
309
+ }
310
+
311
+ /**
312
+ * Create a secure temporary .env file from credentials
313
+ * File is created with restrictive permissions (600) in /tmp
314
+ * @param {object} credentials - Credential key-value pairs
315
+ * @param {string} projectName - Project name for unique filename
316
+ * @returns {string} Path to created temp file
317
+ */
318
+ export function createSecureTempEnvFile(credentials, projectName) {
319
+ const timestamp = Date.now();
320
+ const tempPath = path.join('/tmp', `.buwp-local-${projectName}-${timestamp}.env`);
321
+
322
+ // Build env file content with proper escaping for multiline values
323
+ const lines = Object.entries(credentials).map(([key, value]) => {
324
+ // Escape special characters in values
325
+ const escaped = String(value)
326
+ .replace(/\\\\/g, '\\\\\\\\') // Escape backslashes first
327
+ .replace(/"/g, '\\"') // Escape double quotes
328
+ .replace(/\\n/g, '\\\\n'); // Escape newlines for shell
329
+
330
+ return `${key}="${escaped}"`;
331
+ });
332
+
333
+ const content = lines.join('\n') + '\n';
334
+
335
+ try {
336
+ // Atomic create with strict permissions: user read/write only (600)
337
+ // 'wx' means create exclusive (fail if exists) and writable
338
+ const fd = fs.openSync(tempPath, 'wx', 0o600);
339
+ fs.writeSync(fd, content);
340
+ fs.closeSync(fd);
341
+
342
+ return tempPath;
343
+ } catch (err) {
344
+ throw new Error(`Failed to create secure temp env file: ${err.message}`);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Securely delete a temporary env file
350
+ * Overwrites file contents with zeros before deletion to prevent recovery
351
+ * @param {string} filePath - Path to temp file to delete
352
+ * @returns {boolean} True if deleted successfully, false if error (non-fatal)
353
+ */
354
+ export function secureDeleteTempEnvFile(filePath) {
355
+ try {
356
+ // Check if file exists
357
+ if (!fs.existsSync(filePath)) {
358
+ return true; // Already gone, consider success
359
+ }
360
+
361
+ // Get file size and overwrite with zeros (secure deletion)
362
+ const stat = fs.statSync(filePath);
363
+ const fd = fs.openSync(filePath, 'r+');
364
+ fs.writeSync(fd, Buffer.alloc(stat.size, 0));
365
+ fs.closeSync(fd);
366
+
367
+ // Now delete the file
368
+ fs.unlinkSync(filePath);
369
+ return true;
370
+ } catch (err) {
371
+ // Log warning but don't fail - temp files are ephemeral anyway
372
+ console.warn(chalk.yellow(`⚠️ Warning: Could not securely delete temp file ${filePath}: ${err.message}`));
373
+ return false;
374
+ }
375
+ }
376
+
286
377
  export {
287
378
  loadConfig,
288
379
  validateConfig,
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import * as config from './config.js';
7
7
  import * as composeGenerator from './compose-generator.js';
8
+ import * as keychain from './keychain.js';
8
9
 
9
10
  export const loadConfig = config.loadConfig;
10
11
  export const validateConfig = config.validateConfig;
@@ -14,3 +15,17 @@ export const generateComposeFile = composeGenerator.generateComposeFile;
14
15
  export const CONFIG_FILE_NAME = config.CONFIG_FILE_NAME;
15
16
  export const ENV_FILE_NAME = config.ENV_FILE_NAME;
16
17
  export const DEFAULT_CONFIG = config.DEFAULT_CONFIG;
18
+
19
+ // Keychain exports
20
+ export const setCredential = keychain.setCredential;
21
+ export const getCredential = keychain.getCredential;
22
+ export const hasCredential = keychain.hasCredential;
23
+ export const listCredentials = keychain.listCredentials;
24
+ export const clearAllCredentials = keychain.clearAllCredentials;
25
+ export const isPlatformSupported = keychain.isPlatformSupported;
26
+ export const isMultilineCredential = keychain.isMultilineCredential;
27
+ export const parseCredentialsFile = keychain.parseCredentialsFile;
28
+ export const CREDENTIAL_KEYS = keychain.CREDENTIAL_KEYS;
29
+ export const CREDENTIAL_GROUPS = keychain.CREDENTIAL_GROUPS;
30
+ export const CREDENTIAL_DESCRIPTIONS = keychain.CREDENTIAL_DESCRIPTIONS;
31
+ export const MULTILINE_CREDENTIALS = keychain.MULTILINE_CREDENTIALS;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * macOS Keychain integration for secure credential storage
3
+ * Uses the `security` command-line tool to interact with macOS Keychain
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import fs from 'fs';
8
+
9
+ // Keychain service name for all buwp-local credentials
10
+ const KEYCHAIN_SERVICE = 'buwp-local';
11
+
12
+ /**
13
+ * All credential keys that can be stored in keychain
14
+ */
15
+ export const CREDENTIAL_KEYS = [
16
+ 'WORDPRESS_DB_PASSWORD',
17
+ 'DB_ROOT_PASSWORD',
18
+ 'SP_ENTITY_ID',
19
+ 'IDP_ENTITY_ID',
20
+ 'SHIB_IDP_LOGOUT',
21
+ 'SHIB_SP_KEY',
22
+ 'SHIB_SP_CERT',
23
+ 'S3_UPLOADS_BUCKET',
24
+ 'S3_UPLOADS_REGION',
25
+ 'S3_UPLOADS_ACCESS_KEY_ID',
26
+ 'S3_UPLOADS_SECRET_ACCESS_KEY',
27
+ 'S3_ACCESS_RULES_TABLE',
28
+ 'OLAP',
29
+ 'OLAP_ACCT_NBR',
30
+ 'OLAP_REGION'
31
+ ];
32
+
33
+ /**
34
+ * Credentials organized by functional group
35
+ */
36
+ export const CREDENTIAL_GROUPS = {
37
+ database: ['WORDPRESS_DB_PASSWORD', 'DB_ROOT_PASSWORD'],
38
+ shibboleth: ['SP_ENTITY_ID', 'IDP_ENTITY_ID', 'SHIB_IDP_LOGOUT', 'SHIB_SP_KEY', 'SHIB_SP_CERT'],
39
+ s3: ['S3_UPLOADS_BUCKET', 'S3_UPLOADS_REGION', 'S3_UPLOADS_ACCESS_KEY_ID', 'S3_UPLOADS_SECRET_ACCESS_KEY', 'S3_ACCESS_RULES_TABLE'],
40
+ olap: ['OLAP', 'OLAP_ACCT_NBR', 'OLAP_REGION']
41
+ };
42
+
43
+ /**
44
+ * Credentials that contain multiline content (cryptographic keys, certificates)
45
+ */
46
+ export const MULTILINE_CREDENTIALS = ['SHIB_SP_KEY', 'SHIB_SP_CERT'];
47
+
48
+ /**
49
+ * Human-readable descriptions for each credential
50
+ */
51
+ export const CREDENTIAL_DESCRIPTIONS = {
52
+ WORDPRESS_DB_PASSWORD: 'WordPress database password',
53
+ DB_ROOT_PASSWORD: 'Database root password',
54
+ SP_ENTITY_ID: 'Shibboleth Service Provider Entity ID',
55
+ IDP_ENTITY_ID: 'Shibboleth Identity Provider Entity ID',
56
+ SHIB_IDP_LOGOUT: 'Shibboleth IDP logout URL',
57
+ SHIB_SP_KEY: 'Shibboleth Service Provider private key (multiline)',
58
+ SHIB_SP_CERT: 'Shibboleth Service Provider certificate (multiline)',
59
+ S3_UPLOADS_BUCKET: 'S3 bucket name',
60
+ S3_UPLOADS_REGION: 'S3 region (e.g., us-east-1)',
61
+ S3_UPLOADS_ACCESS_KEY_ID: 'AWS access key ID',
62
+ S3_UPLOADS_SECRET_ACCESS_KEY: 'AWS secret access key',
63
+ S3_ACCESS_RULES_TABLE: 'S3 access rules table name',
64
+ OLAP: 'OLAP name',
65
+ OLAP_ACCT_NBR: 'OLAP account number',
66
+ OLAP_REGION: 'OLAP region'
67
+ };
68
+
69
+ /**
70
+ * Check if the current platform supports keychain operations
71
+ * @returns {boolean} True if macOS, false otherwise
72
+ */
73
+ export function isPlatformSupported() {
74
+ return process.platform === 'darwin';
75
+ }
76
+
77
+ /**
78
+ * Validate that a credential key is recognized
79
+ * @param {string} key - Credential key to validate
80
+ * @returns {boolean} True if valid
81
+ */
82
+ export function isValidCredentialKey(key) {
83
+ return CREDENTIAL_KEYS.includes(key);
84
+ }
85
+
86
+ /**
87
+ * Get the group name for a credential key
88
+ * @param {string} key - Credential key
89
+ * @returns {string|null} Group name or null if not found
90
+ */
91
+ export function getCredentialGroup(key) {
92
+ for (const [groupName, keys] of Object.entries(CREDENTIAL_GROUPS)) {
93
+ if (keys.includes(key)) {
94
+ return groupName;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Check if a credential contains multiline content
102
+ * @param {string} key - Credential key
103
+ * @returns {boolean} True if credential is multiline
104
+ */
105
+ export function isMultilineCredential(key) {
106
+ return MULTILINE_CREDENTIALS.includes(key);
107
+ }
108
+
109
+ /**
110
+ * Set a credential in the macOS keychain
111
+ * Uses -U flag to update existing entries instead of creating duplicates
112
+ * @param {string} key - Credential key (e.g., 'WORDPRESS_DB_PASSWORD')
113
+ * @param {string} value - Credential value
114
+ * @throws {Error} If platform is not supported or operation fails
115
+ */
116
+ export function setCredential(key, value) {
117
+ if (!isPlatformSupported()) {
118
+ throw new Error('Keychain operations are only supported on macOS');
119
+ }
120
+
121
+ if (!isValidCredentialKey(key)) {
122
+ throw new Error(`Invalid credential key: ${key}`);
123
+ }
124
+
125
+ try {
126
+ // Use -U flag to update if exists, create if doesn't
127
+ // -w flag allows password on command line (needed for automation)
128
+ execSync(
129
+ `security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w "${value}" -U`,
130
+ { stdio: 'pipe' }
131
+ );
132
+ } catch (err) {
133
+ throw new Error(`Failed to store credential in keychain: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Get a credential from the macOS keychain
139
+ * @param {string} key - Credential key
140
+ * @returns {string|null} Credential value or null if not found
141
+ * @throws {Error} If platform is not supported
142
+ */
143
+ export function getCredential(key) {
144
+ if (!isPlatformSupported()) {
145
+ throw new Error('Keychain operations are only supported on macOS');
146
+ }
147
+
148
+ if (!isValidCredentialKey(key)) {
149
+ throw new Error(`Invalid credential key: ${key}`);
150
+ }
151
+
152
+ try {
153
+ // -w flag returns only the password (no other metadata)
154
+ let result = execSync(
155
+ `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w`,
156
+ { stdio: 'pipe', encoding: 'utf8' }
157
+ );
158
+ result = result.trim();
159
+
160
+ // Handle hex-encoded values (legacy format for multiline credentials)
161
+ // Check if the value looks like hex (only hex digits, even length)
162
+ if (isMultilineCredential(key) && isHexEncoded(result)) {
163
+ try {
164
+ result = Buffer.from(result, 'hex').toString('utf8');
165
+ } catch (err) {
166
+ // If hex decode fails, return original value
167
+ console.warn(`Warning: Could not decode hex-encoded credential ${key}`);
168
+ }
169
+ }
170
+
171
+ return result;
172
+ } catch (err) {
173
+ // Credential not found
174
+ if (err.message.includes('could not be found')) {
175
+ return null;
176
+ }
177
+ throw new Error(`Failed to retrieve credential from keychain: ${err.message}`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Check if a string appears to be hex-encoded
183
+ * @param {string} value - Value to check
184
+ * @returns {boolean} True if value looks like hex
185
+ */
186
+ function isHexEncoded(value) {
187
+ // Must be even length and only contain hex digits
188
+ return /^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0;
189
+ }
190
+
191
+ /**
192
+ * Check if a credential exists in the keychain
193
+ * @param {string} key - Credential key
194
+ * @returns {boolean} True if credential exists
195
+ */
196
+ export function hasCredential(key) {
197
+ if (!isPlatformSupported()) {
198
+ return false;
199
+ }
200
+
201
+ if (!isValidCredentialKey(key)) {
202
+ return false;
203
+ }
204
+
205
+ try {
206
+ execSync(
207
+ `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}"`,
208
+ { stdio: 'pipe' }
209
+ );
210
+ return true;
211
+ } catch (err) {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Delete a credential from the keychain
218
+ * @param {string} key - Credential key
219
+ * @throws {Error} If platform is not supported or operation fails
220
+ */
221
+ export function deleteCredential(key) {
222
+ if (!isPlatformSupported()) {
223
+ throw new Error('Keychain operations are only supported on macOS');
224
+ }
225
+
226
+ if (!isValidCredentialKey(key)) {
227
+ throw new Error(`Invalid credential key: ${key}`);
228
+ }
229
+
230
+ try {
231
+ execSync(
232
+ `security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}"`,
233
+ { stdio: 'pipe' }
234
+ );
235
+ } catch (err) {
236
+ // Ignore if credential doesn't exist
237
+ if (!err.message.includes('could not be found')) {
238
+ throw new Error(`Failed to delete credential from keychain: ${err.message}`);
239
+ }
240
+
241
+ console.log("foobar");
242
+ }
243
+ }
244
+
245
+ /**
246
+ * List all stored credentials (returns keys only, not values)
247
+ * @returns {string[]} Array of credential keys that are stored
248
+ */
249
+ export function listCredentials() {
250
+ if (!isPlatformSupported()) {
251
+ return [];
252
+ }
253
+
254
+ const storedKeys = [];
255
+
256
+ for (const key of CREDENTIAL_KEYS) {
257
+ if (hasCredential(key)) {
258
+ storedKeys.push(key);
259
+ }
260
+ }
261
+
262
+ return storedKeys;
263
+ }
264
+
265
+ /**
266
+ * Clear all buwp-local credentials from keychain
267
+ * @returns {number} Number of credentials deleted
268
+ */
269
+ export function clearAllCredentials() {
270
+ if (!isPlatformSupported()) {
271
+ throw new Error('Keychain operations are only supported on macOS');
272
+ }
273
+
274
+ let deletedCount = 0;
275
+
276
+ for (const key of CREDENTIAL_KEYS) {
277
+ try {
278
+ deleteCredential(key);
279
+ deletedCount++;
280
+ } catch (err) {
281
+ // Continue deleting others even if one fails
282
+ }
283
+ }
284
+
285
+ return deletedCount;
286
+ }
287
+
288
+ /**
289
+ * Parse credentials from a JSON file
290
+ * Expected format: { "credentials": { "KEY": "value", ... }, "version": "1.0", ... }
291
+ * @param {string} filePath - Path to credentials JSON file
292
+ * @returns {object} { parsed, unknown, metadata }
293
+ */
294
+ export function parseCredentialsFile(filePath) {
295
+ // Read and parse JSON file
296
+ let data;
297
+ try {
298
+ const content = fs.readFileSync(filePath, 'utf8');
299
+ data = JSON.parse(content);
300
+ } catch (err) {
301
+ if (err.code === 'ENOENT') {
302
+ throw new Error(`File not found: ${filePath}`);
303
+ } else if (err instanceof SyntaxError) {
304
+ throw new Error(`Invalid JSON format: ${err.message}`);
305
+ }
306
+ throw new Error(`Failed to read file: ${err.message}`);
307
+ }
308
+
309
+ // Validate structure
310
+ if (!data.credentials || typeof data.credentials !== 'object') {
311
+ throw new Error('Invalid credentials file format: missing "credentials" object');
312
+ }
313
+
314
+ // Filter and validate keys
315
+ const parsed = {};
316
+ const unknown = [];
317
+
318
+ for (const [key, value] of Object.entries(data.credentials)) {
319
+ if (CREDENTIAL_KEYS.includes(key)) {
320
+ // Validate value is non-empty string
321
+ if (typeof value === 'string' && value.trim().length > 0) {
322
+ parsed[key] = value;
323
+ } else {
324
+ unknown.push({ key, reason: 'empty or invalid value' });
325
+ }
326
+ } else {
327
+ unknown.push({ key, reason: 'unknown credential key' });
328
+ }
329
+ }
330
+
331
+ // Extract metadata
332
+ const metadata = {
333
+ version: data.version || 'unknown',
334
+ source: data.source || 'unknown',
335
+ exported: data.exported || null
336
+ };
337
+
338
+ return { parsed, unknown, metadata };
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bostonuniversity/buwp-local",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Local WordPress development environment for Boston University projects",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",