initial transfer
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# Keyman - SSH Key Management with Age Encryption
|
||||
|
||||
Keyman is a simple command line tool built around the `age` encryption tool. It allows you to manage SSH keys in public GitHub repositories securely by encrypting the private keys.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 Encrypt SSH private keys with age encryption
|
||||
- 📁 Organized vault structure: `vault/keys/` for encrypted keys, `vault/tmp/` for decrypted keys
|
||||
- ⚙️ Configurable via `.keymanrc.json` with sensible defaults
|
||||
- 🔍 Interactive CLI for encrypting, decrypting, and listing keys
|
||||
- 🔄 Support for key rotation
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate Age Encryption Key
|
||||
|
||||
```bash
|
||||
# Create vault structure
|
||||
mkdir -p vault/keys vault/tmp
|
||||
|
||||
# Generate age encryption key (keep this secret!)
|
||||
age-keygen -o vault/age.key
|
||||
|
||||
# Add to .gitignore
|
||||
echo "vault/age.key" >> .gitignore
|
||||
echo "vault/tmp/" >> .gitignore
|
||||
```
|
||||
|
||||
### 2. Generate SSH Keys
|
||||
|
||||
```bash
|
||||
# Generate SSH key pair
|
||||
ssh-keygen -t ed25519 -f vault/tmp/id_deploy -N "" -C "deploy@myapp.dev"
|
||||
```
|
||||
|
||||
### 3. Run Keyman
|
||||
|
||||
```bash
|
||||
# Run keyman interactively
|
||||
VAULT_ROOT=./vault keyman
|
||||
|
||||
# Or if you have .keymanrc.json configured, just run:
|
||||
keyman
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Keyman uses sensible defaults but can be customized via `.keymanrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"vaultRoot": "./vault",
|
||||
"keysDir": "keys",
|
||||
"tmpDir": "tmp",
|
||||
"ageKeyFile": "age.key"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
1. **VAULT_ROOT** environment variable (highest priority)
|
||||
2. **.keymanrc.json** file (searched from current directory upward)
|
||||
3. **Default values** (lowest priority)
|
||||
|
||||
### Default Values
|
||||
|
||||
- `vaultRoot`: `"vault"`
|
||||
- `keysDir`: `"keys"`
|
||||
- `tmpDir`: `"tmp"`
|
||||
- `ageKeyFile`: `"age.key"`
|
||||
|
||||
## Vault Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── vault/
|
||||
│ ├── age.key # Master encryption key (NEVER commit!)
|
||||
│ ├── keys/ # Encrypted keys (safe to commit)
|
||||
│ │ └── deploy/ # Each key has its own folder
|
||||
│ │ ├── id_deploy.pub # Public key
|
||||
│ │ └── id_deploy.age # Encrypted private key
|
||||
│ └── tmp/ # Decrypted keys (NEVER commit!)
|
||||
│ ├── id_deploy # Decrypted private key
|
||||
│ └── id_deploy.pub # Public key
|
||||
└── .keymanrc.json # Configuration (optional)
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
Keyman provides an interactive menu-driven interface with the following operations:
|
||||
|
||||
- **📋 List keys** - Compact view showing all keys with checkbox indicators for their locations
|
||||
- **🔒 Encrypt keys** - Encrypt SSH keys from `vault/tmp/` and store in `vault/keys/`
|
||||
- **🔓 Decrypt keys** - Decrypt keys from `vault/keys/` to `vault/tmp/` or `~/.ssh/`
|
||||
- **❌ Quit** - Exit the program
|
||||
|
||||
After completing any operation, keyman automatically returns to the main menu, allowing you to perform multiple operations in a single session without restarting the tool.
|
||||
|
||||
### List Keys Output
|
||||
|
||||
The list command shows a compact, unified view of all SSH keys with their locations:
|
||||
|
||||
```
|
||||
🔑 SSH Keys:
|
||||
|
||||
Key Name [Vault] [Tmp] [.ssh]
|
||||
──────────────────────────────────────────────────────────
|
||||
✅ id_deploy (.pub) [✓] [ ] [✓]
|
||||
🔓 id_github (.pub) [✓] [✓] [ ]
|
||||
🔒 id_backup (.pub) [✓] [ ] [ ]
|
||||
⚠️ id_local (.pub) [ ] [ ] [✓]
|
||||
|
||||
Legend:
|
||||
✅ = Managed (encrypted in vault + active in .ssh)
|
||||
🔓 = Decrypted (in vault + decrypted to tmp)
|
||||
🔒 = Encrypted only (in vault, not decrypted)
|
||||
⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Public keys are indicated with `(.pub)` suffix instead of separate entries
|
||||
- Status emoji shows management state at a glance
|
||||
- Checkboxes `[✓]` show presence in three locations:
|
||||
- **[Vault]** - Encrypted in vault/keys/
|
||||
- **[Tmp]** - Decrypted in vault/tmp/
|
||||
- **[.ssh]** - Active in ~/.ssh/
|
||||
- Alphabetically sorted for easy scanning
|
||||
- New **🔓** status for keys decrypted to tmp but not yet in .ssh
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Using environment variable
|
||||
VAULT_ROOT=../../vault keyman
|
||||
|
||||
# Using default configuration
|
||||
keyman
|
||||
|
||||
# Keyman will show:
|
||||
# 📁 Vault Root: /path/to/vault
|
||||
# 🔑 Keys Directory: /path/to/vault/keys
|
||||
# 📂 Temp Directory: /path/to/vault/tmp
|
||||
# 🔐 Age Key: /path/to/vault/age.key
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Never commit** `vault/age.key` or `vault/tmp/` to version control
|
||||
2. **Always backup** your `age.key` securely (password manager, encrypted USB)
|
||||
3. **Commit** `vault/keys/` - encrypted keys are safe to share
|
||||
4. **Use environment variables** for CI/CD: `VAULT_ROOT=/path/to/vault keyman`
|
||||
5. **Keep .keymanrc.json** in your project root for team consistency
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { keyman } from './keyman.main.js';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--print-config')) {
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(JSON.stringify(paths));
|
||||
process.exit(0);
|
||||
}
|
||||
keyman();
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
declare const KeymanConfigSchema: z.ZodObject<{
|
||||
vaultRoot: z.ZodDefault<z.ZodString>;
|
||||
keysDir: z.ZodDefault<z.ZodString>;
|
||||
tmpDir: z.ZodDefault<z.ZodString>;
|
||||
ageKeyFile: z.ZodDefault<z.ZodString>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
vaultRoot: string;
|
||||
keysDir: string;
|
||||
tmpDir: string;
|
||||
ageKeyFile: string;
|
||||
}, {
|
||||
vaultRoot?: string | undefined;
|
||||
keysDir?: string | undefined;
|
||||
tmpDir?: string | undefined;
|
||||
ageKeyFile?: string | undefined;
|
||||
}>;
|
||||
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
||||
/**
|
||||
* Resolution strategy for merging config properties
|
||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
||||
* - 'override': Child value completely replaces parent value
|
||||
*/
|
||||
export type ResolutionStrategy = 'merge' | 'override';
|
||||
/**
|
||||
* Resolution configuration for customizing merge behavior
|
||||
*/
|
||||
export type KeymanResolutionConfig = {
|
||||
[K in keyof KeymanConfig]?: ResolutionStrategy;
|
||||
};
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface KeymanConfigFile extends Partial<KeymanConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: KeymanResolutionConfig;
|
||||
}
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "vaultRoot": "../vault",
|
||||
* "resolution": {
|
||||
* "vaultRoot": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Validated keyman configuration
|
||||
*/
|
||||
export declare function loadConfig(): KeymanConfig;
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export declare function resolveConfigPaths(config: KeymanConfig): {
|
||||
vaultRoot: string;
|
||||
keysDir: string;
|
||||
tmpDir: string;
|
||||
keyPath: string;
|
||||
};
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export declare function getConfigPaths(): string[];
|
||||
export {};
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
const KeymanConfigSchema = z.object({
|
||||
vaultRoot: z.string().default('vault'),
|
||||
keysDir: z.string().default('keys'),
|
||||
tmpDir: z.string().default('tmp'),
|
||||
ageKeyFile: z.string().default('age.key'),
|
||||
});
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
const CONFIG_FILENAME = '.keymanrc.json';
|
||||
/**
|
||||
* Path-based properties that should be resolved relative to config file location
|
||||
*/
|
||||
const PATH_PROPERTIES = ['vaultRoot'];
|
||||
/**
|
||||
* Resolves path properties in a config object relative to the config file's directory
|
||||
* @param configFile The raw config file contents
|
||||
* @param configDir Directory containing the config file
|
||||
* @returns Config with path properties resolved to absolute paths
|
||||
*/
|
||||
function resolvePathsRelativeToConfig(configFile, configDir) {
|
||||
const resolved = { ...configFile };
|
||||
for (const prop of PATH_PROPERTIES) {
|
||||
const value = configFile[prop];
|
||||
if (typeof value === 'string' && !path.isAbsolute(value)) {
|
||||
resolved[prop] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
* @param startDir Directory to start searching from
|
||||
* @returns Array of paths to .keymanrc.json files
|
||||
*/
|
||||
function findConfigFiles(startDir) {
|
||||
const configPaths = [];
|
||||
let currentDir = startDir;
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(os.homedir(), CONFIG_FILENAME);
|
||||
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
return configPaths;
|
||||
}
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(parentValue, childValue, strategy) {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
const combined = [...parentValue, ...childValue];
|
||||
if (combined.every((v) => typeof v !== 'object')) {
|
||||
return [...new Set(combined)];
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
if (typeof parentValue === 'object' &&
|
||||
parentValue !== null &&
|
||||
typeof childValue === 'object' &&
|
||||
childValue !== null &&
|
||||
!Array.isArray(parentValue) &&
|
||||
!Array.isArray(childValue)) {
|
||||
// Deep merge objects
|
||||
const result = { ...parentValue };
|
||||
for (const [key, value] of Object.entries(childValue)) {
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, 'merge');
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Primitives: child overrides parent
|
||||
return childValue;
|
||||
}
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent, childFile) {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result = { ...parent };
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution')
|
||||
continue; // Skip resolution property itself
|
||||
const strategy = resolution[key] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "vaultRoot": "../vault",
|
||||
* "resolution": {
|
||||
* "vaultRoot": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Validated keyman configuration
|
||||
*/
|
||||
export function loadConfig() {
|
||||
const startDir = process.cwd();
|
||||
const configPaths = findConfigFiles(startDir);
|
||||
if (configPaths.length === 0) {
|
||||
console.error('ℹ️ No .keymanrc.json found, using default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
// Start with defaults and merge each config file
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content);
|
||||
// Resolve path properties relative to the config file's directory
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
console.error(`✅ Loaded configuration from ${configPath}`);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
|
||||
}
|
||||
// Continue with other configs instead of failing entirely
|
||||
}
|
||||
}
|
||||
// Validate the final merged result
|
||||
try {
|
||||
return KeymanConfigSchema.parse(config);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
console.error('ℹ️ Falling back to default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export function resolveConfigPaths(config) {
|
||||
// VAULT_ROOT environment variable takes precedence
|
||||
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
|
||||
return {
|
||||
vaultRoot,
|
||||
keysDir: path.resolve(vaultRoot, config.keysDir),
|
||||
tmpDir: path.resolve(vaultRoot, config.tmpDir),
|
||||
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export function getConfigPaths() {
|
||||
return findConfigFiles(process.cwd());
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function copyKey(sshDir, tmpDir) {
|
||||
const getKeys = (dir) => {
|
||||
if (!fs.existsSync(dir))
|
||||
return [];
|
||||
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
};
|
||||
const sshKeys = getKeys(sshDir);
|
||||
const tmpKeys = getKeys(tmpDir);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No SSH keys found.');
|
||||
return;
|
||||
}
|
||||
const { selectedKey } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selectedKey',
|
||||
message: 'Select key to copy public key from:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
// Determine location of the public key
|
||||
// Prefer tmpDir if it exists there, otherwise sshDir
|
||||
let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`);
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
pubKeyPath = path.join(sshDir, `${selectedKey}.pub`);
|
||||
}
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
console.error(`❌ Public key not found for ${selectedKey}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
||||
// Detect OS and use appropriate clipboard command
|
||||
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
|
||||
// For this specific request on Darwin:
|
||||
const proc = execa('pbcopy');
|
||||
proc.stdin?.write(pubKeyContent);
|
||||
proc.stdin?.end();
|
||||
await proc;
|
||||
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ Failed to copy to clipboard: ${error}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function decryptKeys(sshDir, vaultDir, ageKey) {
|
||||
const keyDir = path.join(vaultDir, 'keys');
|
||||
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
|
||||
const keyfile = path.join(keyDir, key, `id_${key}.age`);
|
||||
console.log(keyfile);
|
||||
return fs.existsSync(keyfile);
|
||||
});
|
||||
if (vaultKeys.length === 0) {
|
||||
console.log('⚠️ No encrypted keys found.');
|
||||
return;
|
||||
}
|
||||
const { selectedKeys, decryptMode } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select keys to decrypt:',
|
||||
choices: vaultKeys,
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'decryptMode',
|
||||
message: 'Choose decryption location:',
|
||||
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
|
||||
},
|
||||
]);
|
||||
for (const key of selectedKeys) {
|
||||
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
|
||||
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
|
||||
const privateKeyOut = decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}`)
|
||||
: path.join(sshDir, `id_${key}`);
|
||||
const publicKeyOut = decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
|
||||
: path.join(sshDir, `id_${key}.pub`);
|
||||
// Decrypt key
|
||||
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
||||
await execa('cp', [publicKey, publicKeyOut]);
|
||||
await execa('chmod', ['600', privateKeyOut]);
|
||||
console.log(`✅ Decrypted: ${privateKeyOut}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function encryptKeys(sshDir, vaultDir, tmpDir, pubkey) {
|
||||
const sshKeys = fs
|
||||
.readdirSync(sshDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
const tmpKeys = fs
|
||||
.readdirSync(tmpDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
console.log(tmpKeys);
|
||||
console.log(sshKeys);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No private SSH keys found to encrypt.');
|
||||
return;
|
||||
}
|
||||
const { selectedKeys } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select SSH keys to encrypt:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
for (const key of selectedKeys) {
|
||||
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
|
||||
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', ''));
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
|
||||
// Copy public key and create README
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function generateKey(tmpDir, keysDir, pubkey) {
|
||||
const { algorithm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'algorithm',
|
||||
message: 'Select algorithm:',
|
||||
choices: ['ed25519', 'rsa'],
|
||||
default: 'ed25519',
|
||||
},
|
||||
]);
|
||||
const { keyName } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'keyName',
|
||||
message: 'Enter key name:',
|
||||
validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'),
|
||||
},
|
||||
]);
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter passphrase (leave empty for no passphrase):',
|
||||
mask: '*',
|
||||
},
|
||||
]);
|
||||
const { identity } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'identity',
|
||||
message: 'Enter key identity (comment):',
|
||||
},
|
||||
]);
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(`Generating ${algorithm} key pair...`);
|
||||
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
|
||||
if (algorithm === 'rsa') {
|
||||
args.push('-b', '4096');
|
||||
}
|
||||
await execa('ssh-keygen', args);
|
||||
console.log(`✅ Key generated: ${keyPath}`);
|
||||
// Encrypt the key
|
||||
const folderName = fileName.replace('id_', '');
|
||||
const vaultPath = path.join(keysDir, folderName);
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
|
||||
// Copy public key
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ Error generating/encrypting key: ${error}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
+115
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export async function listKeys(sshDir, vaultDir, tmpDir) {
|
||||
console.log('\n📂 Checking keys in:');
|
||||
console.log(` SSH: ${sshDir}`);
|
||||
console.log(` Vault: ${vaultDir}`);
|
||||
console.log(` Tmp: ${tmpDir}\n`);
|
||||
const keyMap = new Map();
|
||||
// Scan SSH directory
|
||||
if (fs.existsSync(sshDir)) {
|
||||
const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_'));
|
||||
for (const file of sshFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: !isPub,
|
||||
hasSshPub: isPub,
|
||||
inVault: false,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const key = keyMap.get(keyName);
|
||||
if (isPub) {
|
||||
key.hasSshPub = true;
|
||||
}
|
||||
else {
|
||||
key.inSsh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan tmp directory
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_'));
|
||||
for (const file of tmpFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: false,
|
||||
inTmp: !isPub,
|
||||
hasTmpPub: isPub,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const key = keyMap.get(keyName);
|
||||
if (isPub) {
|
||||
key.hasTmpPub = true;
|
||||
}
|
||||
else {
|
||||
key.inTmp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan vault directory
|
||||
if (fs.existsSync(vaultDir)) {
|
||||
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
||||
const stat = fs.statSync(path.join(vaultDir, dir));
|
||||
return stat.isDirectory();
|
||||
});
|
||||
for (const dir of vaultDirs) {
|
||||
const keyName = `id_${dir}`;
|
||||
const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`);
|
||||
if (fs.existsSync(encryptedPath)) {
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: true,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
keyMap.get(keyName).inVault = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Display results
|
||||
if (keyMap.size === 0) {
|
||||
console.log('⚠️ No SSH keys found.\n');
|
||||
return;
|
||||
}
|
||||
console.log('🔑 SSH Keys:\n');
|
||||
console.log(' Key Name [Vault] [Tmp] [.ssh]');
|
||||
console.log(` ${'─'.repeat(58)}`);
|
||||
const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const key of sortedKeys) {
|
||||
const vaultMark = key.inVault ? '✓' : ' ';
|
||||
const tmpMark = key.inTmp ? '✓' : ' ';
|
||||
const sshMark = key.inSsh ? '✓' : ' ';
|
||||
// Show (.pub) if present in any location
|
||||
const hasPub = key.hasSshPub || key.hasTmpPub;
|
||||
const pubIndicator = hasPub ? ' (.pub)' : '';
|
||||
// Determine status
|
||||
const status = key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ ';
|
||||
const namePart = `${key.name}${pubIndicator}`.padEnd(32);
|
||||
console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`);
|
||||
}
|
||||
console.log('\n Legend:');
|
||||
console.log(' ✅ = Managed (encrypted in vault + active in .ssh)');
|
||||
console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)');
|
||||
console.log(' 🔒 = Encrypted only (in vault, not decrypted)');
|
||||
console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n');
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function keyman(): Promise<void>;
|
||||
Vendored
+79
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import inquirer from 'inquirer';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { copyKey } from './keyman.copy.js';
|
||||
import { decryptKeys } from './keyman.decrypt.js';
|
||||
import { encryptKeys } from './keyman.encrypt.js';
|
||||
import { generateKey } from './keyman.generate.js';
|
||||
import { listKeys } from './keyman.list.js';
|
||||
import { extractAgePublicKey } from './keyman.utils.js';
|
||||
// 🔹 Main function to resolve paths and manage flow
|
||||
export async function keyman() {
|
||||
// Load configuration from .keymanrc.json or use defaults
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
|
||||
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
|
||||
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
|
||||
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
|
||||
// Get USER input
|
||||
const { user } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'user',
|
||||
message: 'Specify USER (default: @current):',
|
||||
default: '@current',
|
||||
},
|
||||
]);
|
||||
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
||||
if (!homeDir) {
|
||||
console.error('Error: Unable to determine HOME directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
const sshDir = path.join(homeDir, '.ssh');
|
||||
fs.mkdirSync(paths.vaultRoot, { recursive: true });
|
||||
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
||||
// Main loop - keep showing menu until user quits
|
||||
let running = true;
|
||||
while (running) {
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
// 🔹 Show category selection
|
||||
const { category } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'category',
|
||||
message: 'Select operation:',
|
||||
choices: [
|
||||
{ name: '📋 List keys', value: 'list' },
|
||||
{ name: '📝 Copy public key', value: 'copy' },
|
||||
{ name: '🆕 Generate key', value: 'generate' },
|
||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||
{ name: '❌ Quit', value: 'quit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
switch (category) {
|
||||
case 'list':
|
||||
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
|
||||
break;
|
||||
case 'copy':
|
||||
await copyKey(sshDir, paths.tmpDir);
|
||||
break;
|
||||
case 'generate':
|
||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath));
|
||||
break;
|
||||
case 'encrypt':
|
||||
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, extractAgePublicKey(paths.keyPath));
|
||||
break;
|
||||
case 'decrypt':
|
||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
||||
break;
|
||||
case 'quit':
|
||||
console.log('\n👋 Goodbye!\n');
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export declare function extractAgePublicKey(keyFilePath: string): string | null;
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs';
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export function extractAgePublicKey(keyFilePath) {
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||
return publicKeyMatch ? publicKeyMatch[1] : null;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ ERROR: Failed to read key file - ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@bitstack/keyman",
|
||||
"description": "A system to simplify ssh key management",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"author": "bitsquare",
|
||||
"bin": "dist/keyman.cli.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsgo && chmod +x dist/keyman.cli.js && npm link",
|
||||
"build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link",
|
||||
"prepublishOnly": "npm run build",
|
||||
"keyman": "node --loader ts-node/esm src/keyman.bin.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=21.0.0"
|
||||
},
|
||||
"files": ["dist/"],
|
||||
"dependencies": {
|
||||
"execa": "9.5.2",
|
||||
"inquirer": "8.2.4",
|
||||
"ts-node": ">=10.9.1",
|
||||
"typed-dotenv": "10.0.2",
|
||||
"typescript": ">=5.6.3",
|
||||
"zod": "^3.24.1",
|
||||
"zx": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { keyman } from './keyman.main.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--print-config')) {
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(JSON.stringify(paths));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
keyman();
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
const KeymanConfigSchema = z.object({
|
||||
vaultRoot: z.string().default('vault'),
|
||||
keysDir: z.string().default('keys'),
|
||||
tmpDir: z.string().default('tmp'),
|
||||
ageKeyFile: z.string().default('age.key'),
|
||||
});
|
||||
|
||||
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
||||
|
||||
/**
|
||||
* Resolution strategy for merging config properties
|
||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
||||
* - 'override': Child value completely replaces parent value
|
||||
*/
|
||||
export type ResolutionStrategy = 'merge' | 'override';
|
||||
|
||||
/**
|
||||
* Resolution configuration for customizing merge behavior
|
||||
*/
|
||||
export type KeymanResolutionConfig = {
|
||||
[K in keyof KeymanConfig]?: ResolutionStrategy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface KeymanConfigFile extends Partial<KeymanConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: KeymanResolutionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG: KeymanConfig = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
|
||||
const CONFIG_FILENAME = '.keymanrc.json';
|
||||
|
||||
/**
|
||||
* Path-based properties that should be resolved relative to config file location
|
||||
*/
|
||||
const PATH_PROPERTIES: (keyof KeymanConfig)[] = ['vaultRoot'];
|
||||
|
||||
/**
|
||||
* Resolves path properties in a config object relative to the config file's directory
|
||||
* @param configFile The raw config file contents
|
||||
* @param configDir Directory containing the config file
|
||||
* @returns Config with path properties resolved to absolute paths
|
||||
*/
|
||||
function resolvePathsRelativeToConfig(
|
||||
configFile: KeymanConfigFile,
|
||||
configDir: string
|
||||
): KeymanConfigFile {
|
||||
const resolved = { ...configFile };
|
||||
|
||||
for (const prop of PATH_PROPERTIES) {
|
||||
const value = configFile[prop];
|
||||
if (typeof value === 'string' && !path.isAbsolute(value)) {
|
||||
resolved[prop] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
* @param startDir Directory to start searching from
|
||||
* @returns Array of paths to .keymanrc.json files
|
||||
*/
|
||||
function findConfigFiles(startDir: string): string[] {
|
||||
const configPaths: string[] = [];
|
||||
let currentDir = startDir;
|
||||
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(os.homedir(), CONFIG_FILENAME);
|
||||
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
|
||||
return configPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(
|
||||
parentValue: unknown,
|
||||
childValue: unknown,
|
||||
strategy: ResolutionStrategy
|
||||
): unknown {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
const combined = [...parentValue, ...childValue];
|
||||
if (combined.every((v) => typeof v !== 'object')) {
|
||||
return [...new Set(combined)];
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parentValue === 'object' &&
|
||||
parentValue !== null &&
|
||||
typeof childValue === 'object' &&
|
||||
childValue !== null &&
|
||||
!Array.isArray(parentValue) &&
|
||||
!Array.isArray(childValue)
|
||||
) {
|
||||
// Deep merge objects
|
||||
const result: Record<string, unknown> = { ...parentValue };
|
||||
for (const [key, value] of Object.entries(childValue)) {
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, 'merge');
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Primitives: child overrides parent
|
||||
return childValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result: Record<string, unknown> = { ...parent };
|
||||
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution') continue; // Skip resolution property itself
|
||||
|
||||
const strategy = resolution[key as keyof KeymanConfig] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as unknown as KeymanConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "vaultRoot": "../vault",
|
||||
* "resolution": {
|
||||
* "vaultRoot": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Validated keyman configuration
|
||||
*/
|
||||
export function loadConfig(): KeymanConfig {
|
||||
const startDir = process.cwd();
|
||||
const configPaths = findConfigFiles(startDir);
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
console.error('ℹ️ No .keymanrc.json found, using default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
// Start with defaults and merge each config file
|
||||
let config: KeymanConfig = { ...DEFAULT_CONFIG };
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content) as KeymanConfigFile;
|
||||
// Resolve path properties relative to the config file's directory
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
console.error(`✅ Loaded configuration from ${configPath}`);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
|
||||
}
|
||||
// Continue with other configs instead of failing entirely
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the final merged result
|
||||
try {
|
||||
return KeymanConfigSchema.parse(config);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
console.error('ℹ️ Falling back to default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export function resolveConfigPaths(config: KeymanConfig) {
|
||||
// VAULT_ROOT environment variable takes precedence
|
||||
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
|
||||
|
||||
return {
|
||||
vaultRoot,
|
||||
keysDir: path.resolve(vaultRoot, config.keysDir),
|
||||
tmpDir: path.resolve(vaultRoot, config.tmpDir),
|
||||
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export function getConfigPaths(): string[] {
|
||||
return findConfigFiles(process.cwd());
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function copyKey(sshDir: string, tmpDir: string) {
|
||||
const getKeys = (dir: string) => {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
};
|
||||
|
||||
const sshKeys = getKeys(sshDir);
|
||||
const tmpKeys = getKeys(tmpDir);
|
||||
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No SSH keys found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKey } = await inquirer.prompt<{ selectedKey: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selectedKey',
|
||||
message: 'Select key to copy public key from:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
|
||||
// Determine location of the public key
|
||||
// Prefer tmpDir if it exists there, otherwise sshDir
|
||||
let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`);
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
pubKeyPath = path.join(sshDir, `${selectedKey}.pub`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
console.error(`❌ Public key not found for ${selectedKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
||||
|
||||
// Detect OS and use appropriate clipboard command
|
||||
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
|
||||
// For this specific request on Darwin:
|
||||
const proc = execa('pbcopy');
|
||||
proc.stdin?.write(pubKeyContent);
|
||||
proc.stdin?.end();
|
||||
await proc;
|
||||
|
||||
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to copy to clipboard: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) {
|
||||
const keyDir = path.join(vaultDir, 'keys');
|
||||
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
|
||||
const keyfile = path.join(keyDir, key, `id_${key}.age`);
|
||||
console.log(keyfile);
|
||||
return fs.existsSync(keyfile);
|
||||
});
|
||||
|
||||
if (vaultKeys.length === 0) {
|
||||
console.log('⚠️ No encrypted keys found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKeys, decryptMode } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select keys to decrypt:',
|
||||
choices: vaultKeys,
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'decryptMode',
|
||||
message: 'Choose decryption location:',
|
||||
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
|
||||
},
|
||||
]);
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
|
||||
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
|
||||
const privateKeyOut =
|
||||
decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}`)
|
||||
: path.join(sshDir, `id_${key}`);
|
||||
const publicKeyOut =
|
||||
decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
|
||||
: path.join(sshDir, `id_${key}.pub`);
|
||||
|
||||
// Decrypt key
|
||||
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
||||
|
||||
await execa('cp', [publicKey, publicKeyOut]);
|
||||
await execa('chmod', ['600', privateKeyOut]);
|
||||
console.log(`✅ Decrypted: ${privateKeyOut}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function encryptKeys(
|
||||
sshDir: string,
|
||||
vaultDir: string,
|
||||
tmpDir: string,
|
||||
pubkey: string
|
||||
) {
|
||||
const sshKeys = fs
|
||||
.readdirSync(sshDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
const tmpKeys = fs
|
||||
.readdirSync(tmpDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
console.log(tmpKeys);
|
||||
console.log(sshKeys);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No private SSH keys found to encrypt.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKeys } = await inquirer.prompt<{ selectedKeys: string[] }>([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select SSH keys to encrypt:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
|
||||
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', ''));
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
|
||||
|
||||
// Copy public key and create README
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
||||
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) {
|
||||
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'algorithm',
|
||||
message: 'Select algorithm:',
|
||||
choices: ['ed25519', 'rsa'],
|
||||
default: 'ed25519',
|
||||
},
|
||||
]);
|
||||
|
||||
const { keyName } = await inquirer.prompt<{ keyName: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'keyName',
|
||||
message: 'Enter key name:',
|
||||
validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'),
|
||||
},
|
||||
]);
|
||||
|
||||
const { password } = await inquirer.prompt<{ password: string }>([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter passphrase (leave empty for no passphrase):',
|
||||
mask: '*',
|
||||
},
|
||||
]);
|
||||
|
||||
const { identity } = await inquirer.prompt<{ identity: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'identity',
|
||||
message: 'Enter key identity (comment):',
|
||||
},
|
||||
]);
|
||||
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Generating ${algorithm} key pair...`);
|
||||
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
|
||||
|
||||
if (algorithm === 'rsa') {
|
||||
args.push('-b', '4096');
|
||||
}
|
||||
|
||||
await execa('ssh-keygen', args);
|
||||
console.log(`✅ Key generated: ${keyPath}`);
|
||||
|
||||
// Encrypt the key
|
||||
const folderName = fileName.replace('id_', '');
|
||||
const vaultPath = path.join(keysDir, folderName);
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
|
||||
|
||||
// Copy public key
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
|
||||
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating/encrypting key: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface KeyInfo {
|
||||
name: string;
|
||||
inSsh: boolean;
|
||||
hasSshPub: boolean;
|
||||
inVault: boolean;
|
||||
inTmp: boolean;
|
||||
hasTmpPub: boolean;
|
||||
}
|
||||
|
||||
export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string) {
|
||||
console.log('\n📂 Checking keys in:');
|
||||
console.log(` SSH: ${sshDir}`);
|
||||
console.log(` Vault: ${vaultDir}`);
|
||||
console.log(` Tmp: ${tmpDir}\n`);
|
||||
|
||||
const keyMap = new Map<string, KeyInfo>();
|
||||
|
||||
// Scan SSH directory
|
||||
if (fs.existsSync(sshDir)) {
|
||||
const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_'));
|
||||
|
||||
for (const file of sshFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: !isPub,
|
||||
hasSshPub: isPub,
|
||||
inVault: false,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
} else {
|
||||
const key = keyMap.get(keyName)!;
|
||||
if (isPub) {
|
||||
key.hasSshPub = true;
|
||||
} else {
|
||||
key.inSsh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan tmp directory
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_'));
|
||||
|
||||
for (const file of tmpFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: false,
|
||||
inTmp: !isPub,
|
||||
hasTmpPub: isPub,
|
||||
});
|
||||
} else {
|
||||
const key = keyMap.get(keyName)!;
|
||||
if (isPub) {
|
||||
key.hasTmpPub = true;
|
||||
} else {
|
||||
key.inTmp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan vault directory
|
||||
if (fs.existsSync(vaultDir)) {
|
||||
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
||||
const stat = fs.statSync(path.join(vaultDir, dir));
|
||||
return stat.isDirectory();
|
||||
});
|
||||
|
||||
for (const dir of vaultDirs) {
|
||||
const keyName = `id_${dir}`;
|
||||
const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`);
|
||||
|
||||
if (fs.existsSync(encryptedPath)) {
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: true,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
} else {
|
||||
keyMap.get(keyName)!.inVault = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display results
|
||||
if (keyMap.size === 0) {
|
||||
console.log('⚠️ No SSH keys found.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔑 SSH Keys:\n');
|
||||
console.log(' Key Name [Vault] [Tmp] [.ssh]');
|
||||
console.log(` ${'─'.repeat(58)}`);
|
||||
|
||||
const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const vaultMark = key.inVault ? '✓' : ' ';
|
||||
const tmpMark = key.inTmp ? '✓' : ' ';
|
||||
const sshMark = key.inSsh ? '✓' : ' ';
|
||||
|
||||
// Show (.pub) if present in any location
|
||||
const hasPub = key.hasSshPub || key.hasTmpPub;
|
||||
const pubIndicator = hasPub ? ' (.pub)' : '';
|
||||
|
||||
// Determine status
|
||||
const status =
|
||||
key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ ';
|
||||
|
||||
const namePart = `${key.name}${pubIndicator}`.padEnd(32);
|
||||
console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`);
|
||||
}
|
||||
|
||||
console.log('\n Legend:');
|
||||
console.log(' ✅ = Managed (encrypted in vault + active in .ssh)');
|
||||
console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)');
|
||||
console.log(' 🔒 = Encrypted only (in vault, not decrypted)');
|
||||
console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n');
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { copyKey } from './keyman.copy.js';
|
||||
import { decryptKeys } from './keyman.decrypt.js';
|
||||
import { encryptKeys } from './keyman.encrypt.js';
|
||||
import { generateKey } from './keyman.generate.js';
|
||||
import { listKeys } from './keyman.list.js';
|
||||
import { extractAgePublicKey } from './keyman.utils.js';
|
||||
|
||||
// 🔹 Main function to resolve paths and manage flow
|
||||
export async function keyman() {
|
||||
// Load configuration from .keymanrc.json or use defaults
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
|
||||
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
|
||||
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
|
||||
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
|
||||
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
|
||||
|
||||
// Get USER input
|
||||
const { user } = await inquirer.prompt<{ user: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'user',
|
||||
message: 'Specify USER (default: @current):',
|
||||
default: '@current',
|
||||
},
|
||||
]);
|
||||
|
||||
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
||||
if (!homeDir) {
|
||||
console.error('Error: Unable to determine HOME directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sshDir = path.join(homeDir, '.ssh');
|
||||
fs.mkdirSync(paths.vaultRoot, { recursive: true });
|
||||
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
||||
|
||||
// Main loop - keep showing menu until user quits
|
||||
let running = true;
|
||||
while (running) {
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
|
||||
// 🔹 Show category selection
|
||||
const { category } = await inquirer.prompt<{ category: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'category',
|
||||
message: 'Select operation:',
|
||||
choices: [
|
||||
{ name: '📋 List keys', value: 'list' },
|
||||
{ name: '📝 Copy public key', value: 'copy' },
|
||||
{ name: '🆕 Generate key', value: 'generate' },
|
||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||
{ name: '❌ Quit', value: 'quit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
switch (category) {
|
||||
case 'list':
|
||||
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
|
||||
break;
|
||||
case 'copy':
|
||||
await copyKey(sshDir, paths.tmpDir);
|
||||
break;
|
||||
case 'generate':
|
||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath)!);
|
||||
break;
|
||||
case 'encrypt':
|
||||
await encryptKeys(
|
||||
sshDir,
|
||||
paths.vaultRoot,
|
||||
paths.tmpDir,
|
||||
extractAgePublicKey(paths.keyPath)!
|
||||
);
|
||||
break;
|
||||
case 'decrypt':
|
||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
||||
break;
|
||||
case 'quit':
|
||||
console.log('\n👋 Goodbye!\n');
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export function extractAgePublicKey(keyFilePath: string): string | null {
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||
|
||||
return publicKeyMatch ? publicKeyMatch[1] : null;
|
||||
} catch (error) {
|
||||
console.error(`❌ ERROR: Failed to read key file - ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2020"],
|
||||
"composite": true,
|
||||
"module": "NodeNext",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["coverage", "node_modules", "dist"],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user