[feat] keyman: key rotation, in two halves
Phase 10 of docs/PLAN.md; closes AUDIT §3.6, the README's oldest lie
("Support for key rotation", with no occurrence of "rotat" in src/).
Rotation only ever adds. `rotateKey` generates a replacement under the next
name in the series — prod → prod-2 → prod-3 — and encrypts it *alongside*
the key it replaces, so both are in the vault at once. `retireKey` is a
separate operation, and the only one in keyman that destroys an encrypted
key. The gap between the two is where the new public key gets deployed and
tested: a rotation that replaces the key in one step locks you out of the
host you were rotating for, because the replacement is not on it yet and
the only copy of the one that is has gone.
The name has to change — the vault layout derives the directory from it, so
a replacement also called `prod` *is* the `prod` entry. `nextRotationName`
skips any version already taken in the vault, in tmp or in .ssh, so it
never asks ssh-keygen to overwrite a private key in use. Retirement warns
when nothing in the vault supersedes the key and then makes the user type
its name, since that deletion is unrecoverable.
Three things extracted rather than copied: `listVaultKeys` (vault.ts) now
backs decrypt, rotate and retire; `createKeyPair` and `promptKeyOptions`
(generate.ts) are shared with rotation, which also carries the old key's
comment over as the default. Verified against the real binaries that a
hyphen-suffixed name survives ssh-keygen and age, that the vault entry
round-trips byte-identically, and that ssh-keygen writes the replacement
0600 without help.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,21 @@ import inquirer from 'inquirer';
|
||||
import { runTool } from './keyman.utils.js';
|
||||
import { storeInVault } from './keyman.vault.js';
|
||||
|
||||
export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) {
|
||||
export interface KeyOptions {
|
||||
algorithm: string;
|
||||
identity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a new key pair should be made: the algorithm and the comment.
|
||||
*
|
||||
* Shared with rotation, which asks the same two questions about a key whose name
|
||||
* it works out for itself.
|
||||
*
|
||||
* @param defaultIdentity offered as the answer — the comment of the key being
|
||||
* replaced, when there is one
|
||||
*/
|
||||
export async function promptKeyOptions(defaultIdentity?: string): Promise<KeyOptions> {
|
||||
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
@@ -15,29 +29,33 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin
|
||||
},
|
||||
]);
|
||||
|
||||
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 { identity } = await inquirer.prompt<{ identity: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'identity',
|
||||
message: 'Enter key identity (comment):',
|
||||
default: defaultIdentity,
|
||||
},
|
||||
]);
|
||||
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
return { algorithm, identity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates one key pair at `keyPath`, reporting a failure rather than throwing.
|
||||
*
|
||||
* @returns whether the key pair was written
|
||||
*/
|
||||
export async function createKeyPair(
|
||||
keyPath: string,
|
||||
algorithm: string,
|
||||
identity: string
|
||||
): Promise<boolean> {
|
||||
const fileName = path.basename(keyPath);
|
||||
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
||||
return;
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${path.dirname(keyPath)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const args = ['-t', algorithm, '-f', keyPath, '-C', identity];
|
||||
@@ -54,8 +72,29 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin
|
||||
// before that. A passphrase keyman never learns cannot be leaked by keyman.
|
||||
await runTool('ssh-keygen', args, { stdio: 'inherit' });
|
||||
console.log(`✅ Key generated: ${keyPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating key: ${error instanceof Error ? error.message : error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) {
|
||||
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 { algorithm, identity } = await promptKeyOptions();
|
||||
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
|
||||
if (!(await createKeyPair(keyPath, algorithm, identity))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user