cryptoserve 0.1.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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Credential storage for CryptoServe platform integration.
3
+ *
4
+ * Stores/reads tokens at ~/.cryptoserve/credentials.json with 0o600 permissions.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+
11
+ const CONFIG_DIR = join(homedir(), '.cryptoserve');
12
+ const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials.json');
13
+
14
+ function ensureDir() {
15
+ if (!existsSync(CONFIG_DIR)) {
16
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
17
+ }
18
+ }
19
+
20
+ export function saveToken(token, server = 'https://localhost:8003') {
21
+ ensureDir();
22
+ const data = {
23
+ token,
24
+ server,
25
+ savedAt: new Date().toISOString(),
26
+ };
27
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
28
+ }
29
+
30
+ export function loadToken() {
31
+ if (!existsSync(CREDENTIALS_PATH)) return null;
32
+ try {
33
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function clearToken() {
40
+ if (existsSync(CREDENTIALS_PATH)) {
41
+ unlinkSync(CREDENTIALS_PATH);
42
+ }
43
+ }
44
+
45
+ export function maskToken(token) {
46
+ if (!token || token.length < 12) return '***';
47
+ return token.slice(0, 8) + '...' + token.slice(-4);
48
+ }
49
+
50
+ export function parseJwtExpiry(token) {
51
+ try {
52
+ const parts = token.split('.');
53
+ if (parts.length !== 3) return null;
54
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
55
+ if (!payload.exp) return null;
56
+ const expiresAt = new Date(payload.exp * 1000);
57
+ const remaining = expiresAt - Date.now();
58
+ return {
59
+ expiresAt,
60
+ remainingMs: remaining,
61
+ expired: remaining <= 0,
62
+ subject: payload.sub || null,
63
+ };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
package/lib/init.mjs ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Project initialization for CryptoServe.
3
+ *
4
+ * `cryptoserve init` sets up:
5
+ * 1. Master key generation + OS keychain storage
6
+ * 2. AI tool protection (block .env from AI context — secretless-ai pattern)
7
+ * 3. Project configuration file
8
+ *
9
+ * Idempotent — safe to run multiple times.
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import {
16
+ generateMasterKey,
17
+ storeMasterKey,
18
+ loadMasterKey,
19
+ isKeychainAvailable,
20
+ promptPassword,
21
+ } from './keychain.mjs';
22
+
23
+ const MARKER = '<!-- cryptoserve:managed -->';
24
+ const CONFIG_DIR = join(homedir(), '.cryptoserve');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // AI tool detection (borrowed from secretless-ai)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const AI_TOOLS = [
31
+ { name: 'Claude Code', markers: ['.claude', 'CLAUDE.md'], hasHooks: true },
32
+ { name: 'Cursor', markers: ['.cursor', '.cursorrules'], hasHooks: false },
33
+ { name: 'Copilot', markers: ['.github/copilot-instructions.md'], hasHooks: false },
34
+ { name: 'Windsurf', markers: ['.windsurf', '.windsurfrules'], hasHooks: false },
35
+ { name: 'Cline', markers: ['.cline', '.clinerules'], hasHooks: false },
36
+ { name: 'Aider', markers: ['.aider', '.aiderignore'], hasHooks: false },
37
+ ];
38
+
39
+ const SECRET_FILE_PATTERNS = [
40
+ '.env', '.env.local', '.env.*',
41
+ '*.key', '*.pem', '*.p12', '*.pfx', '*.crt',
42
+ '.aws/credentials', '.ssh/*', '.docker/config.json',
43
+ '.git-credentials', '.npmrc', '.pypirc',
44
+ '*.tfstate', '*.tfvars',
45
+ 'secrets/', 'credentials/',
46
+ ];
47
+
48
+ function detectAiTools(projectDir) {
49
+ const detected = [];
50
+ for (const tool of AI_TOOLS) {
51
+ for (const marker of tool.markers) {
52
+ if (existsSync(join(projectDir, marker))) {
53
+ detected.push(tool);
54
+ break;
55
+ }
56
+ }
57
+ }
58
+ return detected;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // AI tool protection setup
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function buildSecretlessInstructions() {
66
+ const patterns = SECRET_FILE_PATTERNS.map(p => `- \`${p}\``).join('\n');
67
+ return `
68
+
69
+ ${MARKER}
70
+ ## CryptoServe Security
71
+
72
+ **Blocked file patterns** (never read or reference):
73
+ ${patterns}
74
+
75
+ **If you need a credential:**
76
+ 1. Reference via \`$VAR_NAME\` in shell commands
77
+ 2. Never hardcode credentials in source files
78
+ 3. Use \`cryptoserve vault run -- <command>\` to inject secrets at runtime
79
+ `;
80
+ }
81
+
82
+ function protectClaudeCode(projectDir, result) {
83
+ // 1. Add deny rules to .claude/settings.json
84
+ const claudeDir = join(projectDir, '.claude');
85
+ if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
86
+
87
+ const settingsPath = join(claudeDir, 'settings.json');
88
+ let settings = {};
89
+ if (existsSync(settingsPath)) {
90
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); }
91
+ catch { settings = {}; }
92
+ }
93
+
94
+ if (!settings.permissions) settings.permissions = {};
95
+ if (!Array.isArray(settings.permissions.deny)) settings.permissions.deny = [];
96
+
97
+ const denyRules = [
98
+ 'Read(.env*)', 'Read(*.key)', 'Read(*.pem)', 'Read(*.p12)',
99
+ 'Grep(.env*)', 'Glob(.env*)',
100
+ 'Bash(cat .env*)', 'Bash(head .env*)', 'Bash(tail .env*)',
101
+ ];
102
+ for (const rule of denyRules) {
103
+ if (!settings.permissions.deny.includes(rule)) {
104
+ settings.permissions.deny.push(rule);
105
+ }
106
+ }
107
+
108
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
109
+ result.filesModified.push('.claude/settings.json');
110
+
111
+ // 2. Add instructions to CLAUDE.md
112
+ addInstructions(join(projectDir, 'CLAUDE.md'), result);
113
+ }
114
+
115
+ function protectCursor(projectDir, result) {
116
+ addInstructions(join(projectDir, '.cursorrules'), result);
117
+ }
118
+
119
+ function protectCopilot(projectDir, result) {
120
+ const dir = join(projectDir, '.github');
121
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
122
+ addInstructions(join(dir, 'copilot-instructions.md'), result);
123
+ }
124
+
125
+ function protectWindsurf(projectDir, result) {
126
+ addInstructions(join(projectDir, '.windsurfrules'), result);
127
+ }
128
+
129
+ function protectCline(projectDir, result) {
130
+ addInstructions(join(projectDir, '.clinerules'), result);
131
+ }
132
+
133
+ function protectAider(projectDir, result) {
134
+ const aiderIgnore = join(projectDir, '.aiderignore');
135
+ let content = existsSync(aiderIgnore) ? readFileSync(aiderIgnore, 'utf-8') : '';
136
+ if (content.includes(MARKER)) return;
137
+
138
+ const patterns = SECRET_FILE_PATTERNS.join('\n');
139
+ content += `\n# ${MARKER}\n${patterns}\n`;
140
+ writeFileSync(aiderIgnore, content);
141
+ result.filesModified.push('.aiderignore');
142
+ }
143
+
144
+ function addInstructions(filePath, result) {
145
+ let content = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
146
+ if (content.includes(MARKER)) return;
147
+
148
+ content += buildSecretlessInstructions();
149
+ writeFileSync(filePath, content);
150
+
151
+ const existed = existsSync(filePath);
152
+ if (existed) {
153
+ result.filesModified.push(filePath);
154
+ } else {
155
+ result.filesCreated.push(filePath);
156
+ }
157
+ }
158
+
159
+ const PROTECTORS = {
160
+ 'Claude Code': protectClaudeCode,
161
+ 'Cursor': protectCursor,
162
+ 'Copilot': protectCopilot,
163
+ 'Windsurf': protectWindsurf,
164
+ 'Cline': protectCline,
165
+ 'Aider': protectAider,
166
+ };
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Main init function
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export async function initProject(projectDir, options = {}) {
173
+ const result = {
174
+ toolsDetected: [],
175
+ toolsConfigured: [],
176
+ filesCreated: [],
177
+ filesModified: [],
178
+ keyStorage: null,
179
+ secretsWarning: 0,
180
+ };
181
+
182
+ // 1. Ensure config directory
183
+ if (!existsSync(CONFIG_DIR)) {
184
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
185
+ }
186
+
187
+ // 2. Generate or load master key
188
+ let existingKey = null;
189
+ try { existingKey = await loadMasterKey(); } catch { /* not found */ }
190
+
191
+ if (!existingKey) {
192
+ const keyBase64 = await generateMasterKey();
193
+ const keychainOk = await isKeychainAvailable();
194
+
195
+ if (keychainOk && !options.insecureStorage) {
196
+ result.keyStorage = await storeMasterKey(keyBase64, { useKeychain: true });
197
+ } else if (options.insecureStorage) {
198
+ // Plaintext fallback (requires explicit opt-in)
199
+ writeFileSync(
200
+ join(CONFIG_DIR, 'master.key'),
201
+ keyBase64,
202
+ { mode: 0o600 }
203
+ );
204
+ result.keyStorage = { storage: 'plaintext-file', path: join(CONFIG_DIR, 'master.key') };
205
+ } else {
206
+ // Encrypted file with password
207
+ const pw = await promptPassword('Set vault password (for encrypted key storage): ');
208
+ result.keyStorage = await storeMasterKey(keyBase64, {
209
+ useKeychain: false,
210
+ fallbackPassword: pw,
211
+ });
212
+ }
213
+ } else {
214
+ result.keyStorage = { storage: 'existing' };
215
+ }
216
+
217
+ // 3. Detect and protect AI tools
218
+ const detected = detectAiTools(projectDir);
219
+ result.toolsDetected = detected.map(t => t.name);
220
+
221
+ for (const tool of detected) {
222
+ const protector = PROTECTORS[tool.name];
223
+ if (protector) {
224
+ protector(projectDir, result);
225
+ result.toolsConfigured.push(tool.name);
226
+ }
227
+ }
228
+
229
+ // 4. Create project config
230
+ const configPath = join(projectDir, '.cryptoserve.json');
231
+ if (!existsSync(configPath)) {
232
+ writeFileSync(configPath, JSON.stringify({
233
+ version: 1,
234
+ project: projectDir.split('/').pop(),
235
+ createdAt: new Date().toISOString(),
236
+ }, null, 2));
237
+ result.filesCreated.push('.cryptoserve.json');
238
+ }
239
+
240
+ return result;
241
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * OS keychain integration for secure local key storage.
3
+ *
4
+ * Uses native OS keychain via child_process (zero dependencies):
5
+ * - macOS: /usr/bin/security (Keychain.app)
6
+ * - Linux: secret-tool (freedesktop.org Secret Service / GNOME Keyring)
7
+ * - Windows: cmdkey.exe + PowerShell (Credential Manager)
8
+ *
9
+ * Fallback: encrypted JSON file at ~/.cryptoserve/keystore.enc
10
+ */
11
+
12
+ import { execFile, spawn } from 'node:child_process';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { homedir, platform } from 'node:os';
16
+ import { randomBytes, scryptSync, createCipheriv, createDecipheriv, hkdfSync } from 'node:crypto';
17
+ import { createInterface } from 'node:readline';
18
+
19
+ const SERVICE_NAME = 'cryptoserve';
20
+ const ACCOUNT_NAME = 'master-key';
21
+ const CONFIG_DIR = join(homedir(), '.cryptoserve');
22
+ const KEYSTORE_PATH = join(CONFIG_DIR, 'keystore.enc');
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Platform-specific keychain backends
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function execPromise(cmd, args) {
29
+ return new Promise((resolve, reject) => {
30
+ execFile(cmd, args, { timeout: 10000 }, (err, stdout, stderr) => {
31
+ if (err) return reject(Object.assign(err, { stderr }));
32
+ resolve({ stdout, stderr });
33
+ });
34
+ });
35
+ }
36
+
37
+ function spawnWrite(cmd, args, stdin) {
38
+ return new Promise((resolve, reject) => {
39
+ const proc = spawn(cmd, args, { timeout: 10000 });
40
+ let stderr = '';
41
+ proc.stderr.on('data', d => { stderr += d; });
42
+ proc.stdin.write(stdin);
43
+ proc.stdin.end();
44
+ proc.on('close', code => {
45
+ if (code !== 0) return reject(new Error(`${cmd} exited ${code}: ${stderr}`));
46
+ resolve();
47
+ });
48
+ proc.on('error', reject);
49
+ });
50
+ }
51
+
52
+ const backends = {
53
+ darwin: {
54
+ async set(value) {
55
+ // Delete existing (ignore error if not found)
56
+ try {
57
+ await execPromise('/usr/bin/security', [
58
+ 'delete-generic-password', '-a', ACCOUNT_NAME, '-s', SERVICE_NAME,
59
+ ]);
60
+ } catch { /* not found — OK */ }
61
+ await execPromise('/usr/bin/security', [
62
+ 'add-generic-password', '-a', ACCOUNT_NAME, '-s', SERVICE_NAME,
63
+ '-w', value, '-U',
64
+ ]);
65
+ },
66
+ async get() {
67
+ const { stderr } = await execPromise('/usr/bin/security', [
68
+ 'find-generic-password', '-a', ACCOUNT_NAME, '-s', SERVICE_NAME, '-g',
69
+ ]);
70
+ const match = stderr.match(/password:\s*"(.+)"/);
71
+ if (match) return match[1];
72
+ const hexMatch = stderr.match(/password:\s*0x([0-9A-Fa-f]+)/);
73
+ if (hexMatch) return Buffer.from(hexMatch[1], 'hex').toString();
74
+ throw new Error('Could not parse keychain response');
75
+ },
76
+ async delete() {
77
+ await execPromise('/usr/bin/security', [
78
+ 'delete-generic-password', '-a', ACCOUNT_NAME, '-s', SERVICE_NAME,
79
+ ]);
80
+ },
81
+ },
82
+
83
+ linux: {
84
+ async set(value) {
85
+ await spawnWrite('secret-tool', [
86
+ 'store', '--label', 'CryptoServe Master Key',
87
+ 'service', SERVICE_NAME, 'account', ACCOUNT_NAME,
88
+ ], value);
89
+ },
90
+ async get() {
91
+ const { stdout } = await execPromise('secret-tool', [
92
+ 'lookup', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME,
93
+ ]);
94
+ return stdout.trim();
95
+ },
96
+ async delete() {
97
+ await execPromise('secret-tool', [
98
+ 'clear', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME,
99
+ ]);
100
+ },
101
+ },
102
+
103
+ win32: {
104
+ async set(value) {
105
+ await execPromise('cmdkey.exe', [
106
+ `/generic:${SERVICE_NAME}`, `/user:${ACCOUNT_NAME}`, `/pass:${value}`,
107
+ ]);
108
+ },
109
+ async get() {
110
+ const { stdout } = await execPromise('powershell', [
111
+ '-Command',
112
+ `(New-Object System.Net.NetworkCredential("","$(cmdkey /list:${SERVICE_NAME} | Out-Null; [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR((Get-StoredCredential -Target '${SERVICE_NAME}').Password)))")).Password`,
113
+ ]);
114
+ return stdout.trim();
115
+ },
116
+ async delete() {
117
+ await execPromise('cmdkey.exe', [`/delete:${SERVICE_NAME}`]);
118
+ },
119
+ },
120
+ };
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Encrypted file fallback
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const FALLBACK_SALT_SIZE = 32;
127
+ const FALLBACK_IV_SIZE = 12;
128
+ const FALLBACK_TAG_SIZE = 16;
129
+
130
+ function ensureConfigDir() {
131
+ if (!existsSync(CONFIG_DIR)) {
132
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
133
+ }
134
+ }
135
+
136
+ function encryptForStorage(data, password) {
137
+ const salt = randomBytes(FALLBACK_SALT_SIZE);
138
+ const key = scryptSync(password, salt, 32, { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
139
+ const iv = randomBytes(FALLBACK_IV_SIZE);
140
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
141
+ const encrypted = Buffer.concat([cipher.update(data, 'utf-8'), cipher.final()]);
142
+ const tag = cipher.getAuthTag();
143
+ return Buffer.concat([salt, iv, tag, encrypted]);
144
+ }
145
+
146
+ function decryptFromStorage(packed, password) {
147
+ const salt = packed.subarray(0, FALLBACK_SALT_SIZE);
148
+ const iv = packed.subarray(FALLBACK_SALT_SIZE, FALLBACK_SALT_SIZE + FALLBACK_IV_SIZE);
149
+ const tag = packed.subarray(FALLBACK_SALT_SIZE + FALLBACK_IV_SIZE, FALLBACK_SALT_SIZE + FALLBACK_IV_SIZE + FALLBACK_TAG_SIZE);
150
+ const encrypted = packed.subarray(FALLBACK_SALT_SIZE + FALLBACK_IV_SIZE + FALLBACK_TAG_SIZE);
151
+ const key = scryptSync(password, salt, 32, { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
152
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
153
+ decipher.setAuthTag(tag);
154
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Password prompting
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export function promptPassword(prompt = 'Password: ') {
162
+ return new Promise((resolve) => {
163
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
164
+ // Hide input
165
+ process.stderr.write(prompt);
166
+ const originalWrite = process.stdout.write;
167
+ process.stdout.write = () => true;
168
+
169
+ let password = '';
170
+ process.stdin.setRawMode?.(true);
171
+ process.stdin.resume();
172
+ process.stdin.on('data', function handler(ch) {
173
+ const c = ch.toString();
174
+ if (c === '\n' || c === '\r') {
175
+ process.stdin.setRawMode?.(false);
176
+ process.stdin.removeListener('data', handler);
177
+ process.stdout.write = originalWrite;
178
+ process.stderr.write('\n');
179
+ rl.close();
180
+ resolve(password);
181
+ } else if (c === '\x7f' || c === '\b') {
182
+ password = password.slice(0, -1);
183
+ } else if (c === '\x03') {
184
+ // Ctrl+C
185
+ process.stdout.write = originalWrite;
186
+ rl.close();
187
+ process.exit(1);
188
+ } else {
189
+ password += c;
190
+ }
191
+ });
192
+ });
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Public API
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export async function isKeychainAvailable() {
200
+ const os = platform();
201
+ const backend = backends[os];
202
+ if (!backend) return false;
203
+
204
+ try {
205
+ if (os === 'darwin') {
206
+ await execPromise('/usr/bin/security', ['list-keychains']);
207
+ return true;
208
+ }
209
+ if (os === 'linux') {
210
+ await execPromise('which', ['secret-tool']);
211
+ return true;
212
+ }
213
+ if (os === 'win32') {
214
+ await execPromise('where', ['cmdkey.exe']);
215
+ return true;
216
+ }
217
+ } catch {
218
+ return false;
219
+ }
220
+ return false;
221
+ }
222
+
223
+ export async function generateMasterKey() {
224
+ return randomBytes(32).toString('base64');
225
+ }
226
+
227
+ export async function storeMasterKey(keyBase64, { useKeychain = true, fallbackPassword = null } = {}) {
228
+ ensureConfigDir();
229
+
230
+ if (useKeychain) {
231
+ const backend = backends[platform()];
232
+ if (backend) {
233
+ try {
234
+ await backend.set(keyBase64);
235
+ return { storage: 'keychain', platform: platform() };
236
+ } catch {
237
+ // Keychain failed, fall through to file fallback
238
+ }
239
+ }
240
+ }
241
+
242
+ // Encrypted file fallback
243
+ if (!fallbackPassword) {
244
+ throw new Error(
245
+ 'No keychain available. Use --insecure-storage or provide a password for encrypted file storage.'
246
+ );
247
+ }
248
+
249
+ const encrypted = encryptForStorage(keyBase64, fallbackPassword);
250
+ writeFileSync(KEYSTORE_PATH, encrypted, { mode: 0o600 });
251
+ return { storage: 'encrypted-file', path: KEYSTORE_PATH };
252
+ }
253
+
254
+ export async function loadMasterKey({ fallbackPassword = null } = {}) {
255
+ // Try OS keychain first
256
+ const backend = backends[platform()];
257
+ if (backend) {
258
+ try {
259
+ return await backend.get();
260
+ } catch {
261
+ // Not in keychain, try file fallback
262
+ }
263
+ }
264
+
265
+ // Try encrypted file
266
+ if (existsSync(KEYSTORE_PATH)) {
267
+ if (!fallbackPassword) {
268
+ throw new Error(
269
+ 'Master key is stored in encrypted file. Provide password or use OS keychain.'
270
+ );
271
+ }
272
+ return decryptFromStorage(readFileSync(KEYSTORE_PATH), fallbackPassword);
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ export async function deleteMasterKey() {
279
+ const backend = backends[platform()];
280
+ if (backend) {
281
+ try { await backend.delete(); } catch { /* OK */ }
282
+ }
283
+ // Also remove file fallback if exists
284
+ if (existsSync(KEYSTORE_PATH)) {
285
+ const { unlinkSync } = await import('node:fs');
286
+ unlinkSync(KEYSTORE_PATH);
287
+ }
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Key derivation (HKDF) for per-context/per-project keys
292
+ // ---------------------------------------------------------------------------
293
+
294
+ export function deriveKey(masterKeyBase64, context, keySize = 32) {
295
+ const masterKey = Buffer.from(masterKeyBase64, 'base64');
296
+ return Buffer.from(
297
+ hkdfSync('sha256', masterKey, 'cryptoserve-v1', context, keySize)
298
+ );
299
+ }
300
+
301
+ export function deriveProjectKey(masterKeyBase64, projectName, environment = 'development') {
302
+ return deriveKey(masterKeyBase64, `project:${projectName}:${environment}`);
303
+ }