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.
- package/README.md +183 -0
- package/bin/cryptoserve.mjs +812 -0
- package/lib/cli-style.mjs +217 -0
- package/lib/client.mjs +138 -0
- package/lib/context-resolver.mjs +339 -0
- package/lib/credentials.mjs +67 -0
- package/lib/init.mjs +241 -0
- package/lib/keychain.mjs +303 -0
- package/lib/local-crypto.mjs +218 -0
- package/lib/pqc-engine.mjs +636 -0
- package/lib/scanner.mjs +323 -0
- package/lib/vault.mjs +242 -0
- package/package.json +36 -0
|
@@ -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
|
+
}
|
package/lib/keychain.mjs
ADDED
|
@@ -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
|
+
}
|