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
package/lib/scanner.mjs
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JavaScript/TypeScript crypto dependency and secret scanner.
|
|
3
|
+
*
|
|
4
|
+
* Scans projects for:
|
|
5
|
+
* 1. Cryptographic dependencies (package.json, imports, algorithm strings)
|
|
6
|
+
* 2. Hardcoded secrets (API keys, passwords — patterns from secretless-ai)
|
|
7
|
+
* 3. Certificate/key files (.pem, .key, .crt, .p12)
|
|
8
|
+
*
|
|
9
|
+
* Output matches the library inventory format used by pqc-engine.mjs.
|
|
10
|
+
* Zero dependencies — uses only node:fs and node:path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, relative, extname, basename } from 'node:path';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Known crypto packages → algorithm mappings
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const CRYPTO_PACKAGES = {
|
|
21
|
+
'crypto-js': { algorithms: ['AES', 'DES', '3DES', 'MD5', 'SHA-256', 'SHA-512', 'HMAC'], quantumRisk: 'low', category: 'symmetric' },
|
|
22
|
+
'bcrypt': { algorithms: ['bcrypt'], quantumRisk: 'none', category: 'kdf' },
|
|
23
|
+
'bcryptjs': { algorithms: ['bcrypt'], quantumRisk: 'none', category: 'kdf' },
|
|
24
|
+
'jsonwebtoken': { algorithms: ['RS256', 'HS256', 'ES256'], quantumRisk: 'high', category: 'token' },
|
|
25
|
+
'jose': { algorithms: ['RS256', 'ES256', 'EdDSA', 'AES-GCM'], quantumRisk: 'high', category: 'token' },
|
|
26
|
+
'node-forge': { algorithms: ['RSA', 'AES', 'SHA-256', 'HMAC', 'TLS'], quantumRisk: 'high', category: 'tls' },
|
|
27
|
+
'tweetnacl': { algorithms: ['X25519', 'Ed25519', 'XSalsa20'], quantumRisk: 'high', category: 'asymmetric' },
|
|
28
|
+
'libsodium-wrappers': { algorithms: ['X25519', 'Ed25519', 'ChaCha20', 'AES-256-GCM'], quantumRisk: 'high', category: 'asymmetric' },
|
|
29
|
+
'@noble/curves': { algorithms: ['ECDSA', 'Ed25519', 'X25519'], quantumRisk: 'high', category: 'asymmetric' },
|
|
30
|
+
'@noble/hashes': { algorithms: ['SHA-256', 'SHA-512', 'SHA3', 'Blake2'], quantumRisk: 'low', category: 'hash' },
|
|
31
|
+
'@noble/post-quantum': { algorithms: ['ML-KEM', 'ML-DSA', 'SLH-DSA'], quantumRisk: 'none', category: 'pqc' },
|
|
32
|
+
'openpgp': { algorithms: ['RSA', 'ECDSA', 'AES', 'SHA-256'], quantumRisk: 'high', category: 'asymmetric' },
|
|
33
|
+
'elliptic': { algorithms: ['ECDSA', 'ECDHE', 'Ed25519'], quantumRisk: 'high', category: 'asymmetric' },
|
|
34
|
+
'secp256k1': { algorithms: ['ECDSA'], quantumRisk: 'high', category: 'asymmetric' },
|
|
35
|
+
'argon2': { algorithms: ['Argon2'], quantumRisk: 'none', category: 'kdf' },
|
|
36
|
+
'scrypt': { algorithms: ['scrypt'], quantumRisk: 'none', category: 'kdf' },
|
|
37
|
+
'pbkdf2': { algorithms: ['PBKDF2'], quantumRisk: 'none', category: 'kdf' },
|
|
38
|
+
'tls': { algorithms: ['TLS', 'RSA', 'ECDSA'], quantumRisk: 'high', category: 'tls' },
|
|
39
|
+
'ssh2': { algorithms: ['RSA', 'Ed25519', 'ECDSA', 'AES'], quantumRisk: 'high', category: 'asymmetric' },
|
|
40
|
+
'node-rsa': { algorithms: ['RSA'], quantumRisk: 'high', category: 'asymmetric' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Import/require patterns to detect in source code
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const IMPORT_PATTERNS = [
|
|
48
|
+
{ pattern: /(?:require|from)\s*[('"`]node:crypto[)'"`]/g, lib: 'node:crypto' },
|
|
49
|
+
{ pattern: /(?:require|from)\s*[('"`]crypto[)'"`]/g, lib: 'node:crypto' },
|
|
50
|
+
{ pattern: /createCipheriv\s*\(/g, lib: 'node:crypto', detail: 'cipher' },
|
|
51
|
+
{ pattern: /createDecipheriv\s*\(/g, lib: 'node:crypto', detail: 'cipher' },
|
|
52
|
+
{ pattern: /createSign\s*\(/g, lib: 'node:crypto', detail: 'signature' },
|
|
53
|
+
{ pattern: /createVerify\s*\(/g, lib: 'node:crypto', detail: 'signature' },
|
|
54
|
+
{ pattern: /generateKeyPair(?:Sync)?\s*\(/g, lib: 'node:crypto', detail: 'keygen' },
|
|
55
|
+
{ pattern: /scrypt(?:Sync)?\s*\(/g, lib: 'node:crypto', detail: 'kdf' },
|
|
56
|
+
{ pattern: /pbkdf2(?:Sync)?\s*\(/g, lib: 'node:crypto', detail: 'kdf' },
|
|
57
|
+
{ pattern: /createCipher\s*\(/g, lib: 'node:crypto', detail: 'DEPRECATED-no-iv' },
|
|
58
|
+
{ pattern: /CryptoJS\./g, lib: 'crypto-js' },
|
|
59
|
+
{ pattern: /forge\.\w+/g, lib: 'node-forge' },
|
|
60
|
+
{ pattern: /nacl\.\w+/g, lib: 'tweetnacl' },
|
|
61
|
+
{ pattern: /jwt\.(?:sign|verify|decode)\s*\(/g, lib: 'jsonwebtoken' },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Algorithm string literals to detect
|
|
65
|
+
const ALGO_LITERALS = [
|
|
66
|
+
{ pattern: /['"`]aes-(?:256|128|192)-(?:gcm|cbc|ctr|ecb)['"`]/gi, algo: 'AES' },
|
|
67
|
+
{ pattern: /['"`]chacha20-poly1305['"`]/gi, algo: 'ChaCha20' },
|
|
68
|
+
{ pattern: /['"`]rsa-sha(?:256|384|512)['"`]/gi, algo: 'RSA' },
|
|
69
|
+
{ pattern: /['"`]sha(?:256|384|512|1)['"`]/gi, algo: 'SHA-256' },
|
|
70
|
+
{ pattern: /['"`](?:HS|RS|ES|PS)(?:256|384|512)['"`]/gi, algo: 'RS256' },
|
|
71
|
+
{ pattern: /['"`]ed25519['"`]/gi, algo: 'Ed25519' },
|
|
72
|
+
{ pattern: /minVersion:\s*['"`]TLSv1\.[0-3]['"`]/g, algo: 'TLS' },
|
|
73
|
+
{ pattern: /['"`](?:md5|MD5)['"`]/g, algo: 'MD5' },
|
|
74
|
+
{ pattern: /['"`](?:des|DES|3des|3DES|des-ede3)['"`]/gi, algo: 'DES' },
|
|
75
|
+
{ pattern: /['"`](?:rc4|RC4)['"`]/gi, algo: 'RC4' },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Deprecated/weak patterns
|
|
79
|
+
const WEAK_PATTERNS = [
|
|
80
|
+
{ pattern: /createCipher\s*\(\s*['"`]/g, issue: 'createCipher without IV (use createCipheriv)', severity: 'critical' },
|
|
81
|
+
{ pattern: /['"`](?:md5|MD5)['"`]/g, issue: 'MD5 is cryptographically broken', severity: 'high' },
|
|
82
|
+
{ pattern: /['"`](?:des|DES)['"`]/g, issue: 'DES has 56-bit keys (use AES)', severity: 'critical' },
|
|
83
|
+
{ pattern: /['"`](?:rc4|RC4)['"`]/g, issue: 'RC4 is broken (use AES-GCM or ChaCha20)', severity: 'critical' },
|
|
84
|
+
{ pattern: /['"`]aes-\d+-ecb['"`]/gi, issue: 'ECB mode leaks patterns (use GCM or CTR)', severity: 'high' },
|
|
85
|
+
{ pattern: /['"`]aes-\d+-cbc['"`]/gi, issue: 'CBC mode is vulnerable to padding oracles (use GCM)', severity: 'medium' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Hardcoded secret detection (borrowed from secretless-ai patterns)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const SECRET_PATTERNS = [
|
|
93
|
+
{ id: 'anthropic', regex: /sk-ant-api\d{2}-[a-zA-Z0-9_-]{20,}/g, name: 'Anthropic API Key', envVar: 'ANTHROPIC_API_KEY' },
|
|
94
|
+
{ id: 'openai-proj', regex: /sk-proj-[a-zA-Z0-9]{20,}/g, name: 'OpenAI Project Key', envVar: 'OPENAI_API_KEY' },
|
|
95
|
+
{ id: 'openai-legacy', regex: /sk-[a-zA-Z0-9]{48,}/g, name: 'OpenAI Legacy Key', envVar: 'OPENAI_API_KEY' },
|
|
96
|
+
{ id: 'aws-access', regex: /AKIA[0-9A-Z]{16}/g, name: 'AWS Access Key', envVar: 'AWS_ACCESS_KEY_ID' },
|
|
97
|
+
{ id: 'github-pat', regex: /ghp_[a-zA-Z0-9]{36}/g, name: 'GitHub PAT', envVar: 'GITHUB_TOKEN' },
|
|
98
|
+
{ id: 'github-fine', regex: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g, name: 'GitHub Fine-grained', envVar: 'GITHUB_TOKEN' },
|
|
99
|
+
{ id: 'slack', regex: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}/g, name: 'Slack Token', envVar: 'SLACK_TOKEN' },
|
|
100
|
+
{ id: 'google', regex: /AIza[0-9A-Za-z_-]{35}/g, name: 'Google API Key', envVar: 'GOOGLE_API_KEY' },
|
|
101
|
+
{ id: 'stripe', regex: /sk_live_[0-9a-zA-Z]{24,}/g, name: 'Stripe Secret Key', envVar: 'STRIPE_SECRET_KEY' },
|
|
102
|
+
{ id: 'sendgrid', regex: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g, name: 'SendGrid Key', envVar: 'SENDGRID_API_KEY' },
|
|
103
|
+
{ id: 'npm', regex: /npm_[a-zA-Z0-9]{36}/g, name: 'npm Token', envVar: 'NPM_TOKEN' },
|
|
104
|
+
{ id: 'private-key', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g, name: 'Private Key', envVar: null },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// File patterns for cert/key discovery
|
|
108
|
+
const CERT_EXTENSIONS = new Set(['.pem', '.key', '.crt', '.p12', '.pfx', '.jks', '.keystore']);
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// File walker
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const SKIP_DIRS = new Set([
|
|
115
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
116
|
+
'.cache', '.nuxt', '.output', '.svelte-kit', '__pycache__',
|
|
117
|
+
'vendor', '.venv', 'venv',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx']);
|
|
121
|
+
|
|
122
|
+
function walkFiles(dir, maxFiles = 10000, maxBytes = 500 * 1024 * 1024) {
|
|
123
|
+
const files = [];
|
|
124
|
+
let totalBytes = 0;
|
|
125
|
+
|
|
126
|
+
function walk(currentDir) {
|
|
127
|
+
if (files.length >= maxFiles || totalBytes >= maxBytes) return;
|
|
128
|
+
|
|
129
|
+
let entries;
|
|
130
|
+
try { entries = readdirSync(currentDir, { withFileTypes: true }); }
|
|
131
|
+
catch { return; }
|
|
132
|
+
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (files.length >= maxFiles || totalBytes >= maxBytes) return;
|
|
135
|
+
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
138
|
+
walk(join(currentDir, entry.name));
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!entry.isFile()) continue;
|
|
144
|
+
|
|
145
|
+
const filePath = join(currentDir, entry.name);
|
|
146
|
+
try {
|
|
147
|
+
const stat = statSync(filePath);
|
|
148
|
+
if (stat.size > 1024 * 1024) continue; // Skip files >1MB
|
|
149
|
+
totalBytes += stat.size;
|
|
150
|
+
files.push(filePath);
|
|
151
|
+
} catch { continue; }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
walk(dir);
|
|
156
|
+
return files;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Scanner
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export function scanProject(projectDir) {
|
|
164
|
+
const results = {
|
|
165
|
+
libraries: [],
|
|
166
|
+
secrets: [],
|
|
167
|
+
weakPatterns: [],
|
|
168
|
+
certFiles: [],
|
|
169
|
+
filesScanned: 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// 1. Scan package.json for crypto dependencies
|
|
173
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
174
|
+
if (existsSync(pkgPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
177
|
+
const allDeps = {
|
|
178
|
+
...(pkg.dependencies || {}),
|
|
179
|
+
...(pkg.devDependencies || {}),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
183
|
+
if (name in CRYPTO_PACKAGES) {
|
|
184
|
+
const info = CRYPTO_PACKAGES[name];
|
|
185
|
+
results.libraries.push({
|
|
186
|
+
name,
|
|
187
|
+
version: version.replace(/^[\^~]/, ''),
|
|
188
|
+
algorithms: info.algorithms,
|
|
189
|
+
quantumRisk: info.quantumRisk,
|
|
190
|
+
category: info.category,
|
|
191
|
+
source: 'package.json',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch { /* invalid package.json */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 2. Walk source files
|
|
199
|
+
const files = walkFiles(projectDir);
|
|
200
|
+
const seenImports = new Set();
|
|
201
|
+
const seenAlgos = new Set();
|
|
202
|
+
|
|
203
|
+
for (const filePath of files) {
|
|
204
|
+
const ext = extname(filePath);
|
|
205
|
+
|
|
206
|
+
// Check for cert/key files
|
|
207
|
+
if (CERT_EXTENSIONS.has(ext)) {
|
|
208
|
+
results.certFiles.push(relative(projectDir, filePath));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Only scan source files for code patterns
|
|
213
|
+
if (!SOURCE_EXTENSIONS.has(ext)) continue;
|
|
214
|
+
|
|
215
|
+
results.filesScanned++;
|
|
216
|
+
|
|
217
|
+
let content;
|
|
218
|
+
try { content = readFileSync(filePath, 'utf-8'); }
|
|
219
|
+
catch { continue; }
|
|
220
|
+
|
|
221
|
+
const relPath = relative(projectDir, filePath);
|
|
222
|
+
|
|
223
|
+
// Detect imports/requires
|
|
224
|
+
for (const { pattern, lib, detail } of IMPORT_PATTERNS) {
|
|
225
|
+
pattern.lastIndex = 0;
|
|
226
|
+
if (pattern.test(content)) {
|
|
227
|
+
const key = `${lib}:${detail || ''}`;
|
|
228
|
+
if (!seenImports.has(key)) {
|
|
229
|
+
seenImports.add(key);
|
|
230
|
+
if (detail === 'DEPRECATED-no-iv') {
|
|
231
|
+
results.weakPatterns.push({
|
|
232
|
+
file: relPath,
|
|
233
|
+
issue: 'createCipher without IV (use createCipheriv)',
|
|
234
|
+
severity: 'critical',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Detect algorithm string literals
|
|
242
|
+
for (const { pattern, algo } of ALGO_LITERALS) {
|
|
243
|
+
pattern.lastIndex = 0;
|
|
244
|
+
if (pattern.test(content) && !seenAlgos.has(algo)) {
|
|
245
|
+
seenAlgos.add(algo);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Detect weak/deprecated patterns
|
|
250
|
+
for (const { pattern, issue, severity } of WEAK_PATTERNS) {
|
|
251
|
+
pattern.lastIndex = 0;
|
|
252
|
+
if (pattern.test(content)) {
|
|
253
|
+
results.weakPatterns.push({ file: relPath, issue, severity });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Detect hardcoded secrets
|
|
258
|
+
for (const line of content.split('\n')) {
|
|
259
|
+
if (line.length > 4096) continue; // ReDoS protection
|
|
260
|
+
// Skip env var references
|
|
261
|
+
if (/\$\{[A-Z_]+\}/.test(line) || /process\.env\.[A-Z_]+/.test(line)) continue;
|
|
262
|
+
|
|
263
|
+
for (const { id, regex, name, envVar } of SECRET_PATTERNS) {
|
|
264
|
+
regex.lastIndex = 0;
|
|
265
|
+
if (regex.test(line)) {
|
|
266
|
+
results.secrets.push({
|
|
267
|
+
type: id,
|
|
268
|
+
name,
|
|
269
|
+
file: relPath,
|
|
270
|
+
envVar,
|
|
271
|
+
severity: 'critical',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Add node:crypto as a library if imports were detected
|
|
279
|
+
if (seenImports.size > 0 || seenAlgos.size > 0) {
|
|
280
|
+
const nodeCryptoAlgos = [...seenAlgos];
|
|
281
|
+
if (seenImports.has('node:crypto:') || seenImports.has('node:crypto:cipher')) {
|
|
282
|
+
if (!nodeCryptoAlgos.includes('AES')) nodeCryptoAlgos.push('AES');
|
|
283
|
+
}
|
|
284
|
+
if (seenImports.has('node:crypto:signature')) {
|
|
285
|
+
if (!nodeCryptoAlgos.includes('RSA')) nodeCryptoAlgos.push('RSA');
|
|
286
|
+
}
|
|
287
|
+
if (seenImports.has('node:crypto:kdf')) {
|
|
288
|
+
nodeCryptoAlgos.push('scrypt');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (nodeCryptoAlgos.length > 0) {
|
|
292
|
+
// Determine quantum risk based on detected algorithms
|
|
293
|
+
const hasAsymmetric = nodeCryptoAlgos.some(a =>
|
|
294
|
+
['RSA', 'ECDSA', 'Ed25519', 'RS256', 'ES256', 'DH'].includes(a)
|
|
295
|
+
);
|
|
296
|
+
results.libraries.push({
|
|
297
|
+
name: 'node:crypto',
|
|
298
|
+
version: 'builtin',
|
|
299
|
+
algorithms: nodeCryptoAlgos,
|
|
300
|
+
quantumRisk: hasAsymmetric ? 'high' : 'low',
|
|
301
|
+
category: hasAsymmetric ? 'asymmetric' : 'symmetric',
|
|
302
|
+
source: 'source-code',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return results;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Format results as library inventory (for PQC engine input)
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
export function toLibraryInventory(scanResults) {
|
|
315
|
+
return scanResults.libraries.map(lib => ({
|
|
316
|
+
name: lib.name,
|
|
317
|
+
version: lib.version,
|
|
318
|
+
algorithms: lib.algorithms,
|
|
319
|
+
quantumRisk: lib.quantumRisk,
|
|
320
|
+
category: lib.category,
|
|
321
|
+
isDeprecated: false,
|
|
322
|
+
}));
|
|
323
|
+
}
|
package/lib/vault.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted local vault for secret management.
|
|
3
|
+
*
|
|
4
|
+
* Stores secrets in an AES-256-GCM encrypted JSON file at
|
|
5
|
+
* ~/.cryptoserve/vault.enc. Master password is used to derive the
|
|
6
|
+
* encryption key via scrypt.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies — uses only node:crypto and node:fs.
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* vault init — Create a new vault
|
|
12
|
+
* vault set K V — Store a secret
|
|
13
|
+
* vault get K — Retrieve a secret
|
|
14
|
+
* vault list — List secret names (not values)
|
|
15
|
+
* vault delete K — Remove a secret
|
|
16
|
+
* vault run -- CMD — Inject secrets as env vars, run command
|
|
17
|
+
* vault import F — Import .env file into vault
|
|
18
|
+
* vault export — Export as encrypted bundle
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
|
|
27
|
+
const CONFIG_DIR = join(homedir(), '.cryptoserve');
|
|
28
|
+
const VAULT_PATH = join(CONFIG_DIR, 'vault.enc');
|
|
29
|
+
const SALT_SIZE = 32;
|
|
30
|
+
const IV_SIZE = 12;
|
|
31
|
+
const TAG_SIZE = 16;
|
|
32
|
+
const SCRYPT_OPTS = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Internal helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function ensureConfigDir() {
|
|
39
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
40
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deriveKey(password, salt) {
|
|
45
|
+
return scryptSync(password, salt, 32, SCRYPT_OPTS);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encryptData(plaintext, password) {
|
|
49
|
+
const salt = randomBytes(SALT_SIZE);
|
|
50
|
+
const key = deriveKey(password, salt);
|
|
51
|
+
const iv = randomBytes(IV_SIZE);
|
|
52
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
53
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
|
54
|
+
const tag = cipher.getAuthTag();
|
|
55
|
+
return Buffer.concat([salt, iv, tag, enc]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function decryptData(packed, password) {
|
|
59
|
+
if (packed.length < SALT_SIZE + IV_SIZE + TAG_SIZE + 1) {
|
|
60
|
+
throw new Error('Vault file is corrupted or empty');
|
|
61
|
+
}
|
|
62
|
+
const salt = packed.subarray(0, SALT_SIZE);
|
|
63
|
+
const iv = packed.subarray(SALT_SIZE, SALT_SIZE + IV_SIZE);
|
|
64
|
+
const tag = packed.subarray(SALT_SIZE + IV_SIZE, SALT_SIZE + IV_SIZE + TAG_SIZE);
|
|
65
|
+
const enc = packed.subarray(SALT_SIZE + IV_SIZE + TAG_SIZE);
|
|
66
|
+
const key = deriveKey(password, salt);
|
|
67
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
68
|
+
decipher.setAuthTag(tag);
|
|
69
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf-8');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadVault(password, path = VAULT_PATH) {
|
|
73
|
+
if (!existsSync(path)) return null;
|
|
74
|
+
const packed = readFileSync(path);
|
|
75
|
+
const json = decryptData(packed, password);
|
|
76
|
+
return JSON.parse(json);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function saveVault(data, password, path = VAULT_PATH) {
|
|
80
|
+
ensureConfigDir();
|
|
81
|
+
const json = JSON.stringify(data, null, 2);
|
|
82
|
+
const encrypted = encryptData(json, password);
|
|
83
|
+
writeFileSync(path, encrypted, { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Public API
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export function vaultExists(path = VAULT_PATH) {
|
|
91
|
+
return existsSync(path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function initVault(password, path = VAULT_PATH) {
|
|
95
|
+
if (existsSync(path)) {
|
|
96
|
+
throw new Error('Vault already exists. Use "vault reset" to recreate.');
|
|
97
|
+
}
|
|
98
|
+
const data = {
|
|
99
|
+
version: 1,
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
secrets: {},
|
|
102
|
+
};
|
|
103
|
+
saveVault(data, password, path);
|
|
104
|
+
return data;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function setSecret(password, key, value, path = VAULT_PATH) {
|
|
108
|
+
const data = loadVault(password, path);
|
|
109
|
+
if (!data) throw new Error('Vault not found. Run "cryptoserve vault init" first.');
|
|
110
|
+
data.secrets[key] = {
|
|
111
|
+
value,
|
|
112
|
+
updatedAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
saveVault(data, password, path);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getSecret(password, key, path = VAULT_PATH) {
|
|
118
|
+
const data = loadVault(password, path);
|
|
119
|
+
if (!data) throw new Error('Vault not found. Run "cryptoserve vault init" first.');
|
|
120
|
+
const entry = data.secrets[key];
|
|
121
|
+
if (!entry) return null;
|
|
122
|
+
return entry.value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function listSecrets(password, path = VAULT_PATH) {
|
|
126
|
+
const data = loadVault(password, path);
|
|
127
|
+
if (!data) throw new Error('Vault not found. Run "cryptoserve vault init" first.');
|
|
128
|
+
return Object.entries(data.secrets).map(([key, entry]) => ({
|
|
129
|
+
key,
|
|
130
|
+
updatedAt: entry.updatedAt,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function deleteSecret(password, key, path = VAULT_PATH) {
|
|
135
|
+
const data = loadVault(password, path);
|
|
136
|
+
if (!data) throw new Error('Vault not found. Run "cryptoserve vault init" first.');
|
|
137
|
+
if (!(key in data.secrets)) return false;
|
|
138
|
+
delete data.secrets[key];
|
|
139
|
+
saveVault(data, password, path);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resetVault(path = VAULT_PATH) {
|
|
144
|
+
if (existsSync(path)) unlinkSync(path);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// vault run — inject secrets as env vars into a child process
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
export function vaultRun(password, command, args = [], path = VAULT_PATH) {
|
|
152
|
+
const data = loadVault(password, path);
|
|
153
|
+
if (!data) throw new Error('Vault not found. Run "cryptoserve vault init" first.');
|
|
154
|
+
|
|
155
|
+
const secretEnv = {};
|
|
156
|
+
for (const [key, entry] of Object.entries(data.secrets)) {
|
|
157
|
+
secretEnv[key] = entry.value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const child = spawn(command, args, {
|
|
162
|
+
stdio: 'inherit',
|
|
163
|
+
env: { ...process.env, ...secretEnv },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
child.on('close', code => resolve(code));
|
|
167
|
+
child.on('error', reject);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// vault import — read .env file, store each key in vault
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export function importEnvFile(password, envPath, path = VAULT_PATH) {
|
|
176
|
+
if (!existsSync(envPath)) throw new Error(`File not found: ${envPath}`);
|
|
177
|
+
|
|
178
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
179
|
+
const lines = content.split('\n');
|
|
180
|
+
let imported = 0;
|
|
181
|
+
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
185
|
+
|
|
186
|
+
// Parse KEY=VALUE (supports optional quotes)
|
|
187
|
+
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
188
|
+
if (!match) continue;
|
|
189
|
+
|
|
190
|
+
const [, key, rawValue] = match;
|
|
191
|
+
// Strip surrounding quotes
|
|
192
|
+
let value = rawValue;
|
|
193
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
194
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
195
|
+
value = value.slice(1, -1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setSecret(password, key, value, path);
|
|
199
|
+
imported++;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return imported;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// vault export — create encrypted bundle
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
export function exportVault(password, exportPassword = null, path = VAULT_PATH) {
|
|
210
|
+
const data = loadVault(password, path);
|
|
211
|
+
if (!data) throw new Error('Vault not found.');
|
|
212
|
+
|
|
213
|
+
const json = JSON.stringify(data.secrets);
|
|
214
|
+
const pw = exportPassword || password;
|
|
215
|
+
return encryptData(json, pw).toString('base64');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function importVaultBundle(password, bundle, importPassword = null, path = VAULT_PATH) {
|
|
219
|
+
const packed = Buffer.from(bundle, 'base64');
|
|
220
|
+
const pw = importPassword || password;
|
|
221
|
+
const json = decryptData(packed, pw);
|
|
222
|
+
const secrets = JSON.parse(json);
|
|
223
|
+
|
|
224
|
+
const data = loadVault(password, path) || {
|
|
225
|
+
version: 1,
|
|
226
|
+
createdAt: new Date().toISOString(),
|
|
227
|
+
secrets: {},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const [key, entry] of Object.entries(secrets)) {
|
|
231
|
+
data.secrets[key] = typeof entry === 'object' ? entry : {
|
|
232
|
+
value: entry,
|
|
233
|
+
updatedAt: new Date().toISOString(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
saveVault(data, password, path);
|
|
238
|
+
return Object.keys(secrets).length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Re-export for testing
|
|
242
|
+
export { encryptData as _encryptData, decryptData as _decryptData };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cryptoserve",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CryptoServe CLI - Cryptographic scanning, PQC analysis, encryption, and local key management",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cryptoserve": "./bin/cryptoserve.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"lib/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cryptography",
|
|
21
|
+
"pqc",
|
|
22
|
+
"post-quantum",
|
|
23
|
+
"encryption",
|
|
24
|
+
"security",
|
|
25
|
+
"scanner",
|
|
26
|
+
"keychain",
|
|
27
|
+
"vault"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/ecolibria/cryptoserve"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test test/*.test.mjs"
|
|
35
|
+
}
|
|
36
|
+
}
|