[keyman] phase 3: derive the age recipient, and survive not having one
main.ts asserted the recipient non-null twice — extractAgePublicKey(...)! — and the type already said null was possible. With no age.key the vault encrypted to the string "null": execa stringifies it, age exits 1, and on the generate path that happens *after* ssh-keygen has written a plaintext private key into tmpDir, so the user is told the operation failed and left with a key on disk. Now the recipient is resolved once, remembered on success, and a null prints the remedy (age-keygen -o <path>) and returns to the menu. list, copy and decrypt still work without one. extractAgePublicKey now derives the public key with `age-keygen -y` instead of scraping the `# public key:` comment. The comment is ordinary text nothing re-checks; verified that rewriting it does not change what -y reports, so a stale or forged comment silently encrypted the vault to a recipient nobody holds the private half of. The comment survives as a fallback for a machine with no age-keygen, behind a warning that it is unverified — but not when age-keygen runs and refuses the file. That means age cannot read the identity, and trusting the comment there would encrypt to a recipient the vault could never decrypt with. runTool throws ToolNotFoundError for ENOENT so the two cases can be told apart. Its own tests move to tool.test.ts, which keeps real processes; utils.test.ts mocks execa, since the gate cannot require age installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,18 @@ export async function keyman() {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Resolved on demand, because only generate and encrypt need a recipient, and
|
||||
// remembered once it succeeds. Retried while it has not: creating the identity
|
||||
// mid-session should not mean restarting.
|
||||
let recipient: string | null = null;
|
||||
const ageRecipient = async () => {
|
||||
recipient ??= await extractAgePublicKey(paths.keyPath);
|
||||
if (!recipient) {
|
||||
console.error(` Create one with: age-keygen -o ${paths.keyPath}`);
|
||||
}
|
||||
return recipient;
|
||||
};
|
||||
|
||||
// Main loop - keep showing menu until user quits
|
||||
let running = true;
|
||||
while (running) {
|
||||
@@ -73,17 +85,20 @@ export async function keyman() {
|
||||
case 'copy':
|
||||
await copyKey(sshDir, paths.tmpDir);
|
||||
break;
|
||||
case 'generate':
|
||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath)!);
|
||||
case 'generate': {
|
||||
const pubkey = await ageRecipient();
|
||||
if (pubkey) {
|
||||
await generateKey(paths.tmpDir, paths.keysDir, pubkey);
|
||||
}
|
||||
break;
|
||||
case 'encrypt':
|
||||
await encryptKeys(
|
||||
sshDir,
|
||||
paths.vaultRoot,
|
||||
paths.tmpDir,
|
||||
extractAgePublicKey(paths.keyPath)!
|
||||
);
|
||||
}
|
||||
case 'encrypt': {
|
||||
const pubkey = await ageRecipient();
|
||||
if (pubkey) {
|
||||
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, pubkey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'decrypt':
|
||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import fs from 'node:fs';
|
||||
import { execa, type Options } from 'execa';
|
||||
|
||||
/** A binary keyman needs is not installed — recoverable, unlike a tool refusing */
|
||||
export class ToolNotFoundError extends Error {
|
||||
constructor(readonly binary: string) {
|
||||
super(`\`${binary}\` was not found on PATH. Install it and try again.`);
|
||||
this.name = 'ToolNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of the external binaries keyman depends on.
|
||||
*
|
||||
@@ -27,23 +35,57 @@ export async function runTool(
|
||||
} catch (error) {
|
||||
const failure = error as { code?: string; stderr?: string; shortMessage?: string };
|
||||
if (failure.code === 'ENOENT') {
|
||||
throw new Error(`\`${binary}\` was not found on PATH. Install it and try again.`);
|
||||
throw new ToolNotFoundError(binary);
|
||||
}
|
||||
throw new Error(`\`${binary}\` failed: ${failure.stderr?.trim() || failure.shortMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The age recipient a vault encrypts to, derived from its identity file.
|
||||
*
|
||||
* `age-keygen -y` derives the public key from the secret key, so it cannot
|
||||
* disagree with it. The `# public key:` comment can: it is ordinary text that
|
||||
* nothing re-checks, and a wrong one encrypts the vault to a recipient nobody
|
||||
* holds the private half of. Verified — rewriting the comment does not change
|
||||
* what `-y` reports.
|
||||
*
|
||||
* The comment stays as a fallback for a machine with no `age-keygen`, behind a
|
||||
* warning that it is unverified. It is *not* a fallback for `age-keygen`
|
||||
* refusing the file: that means age cannot read the identity, and trusting the
|
||||
* comment then would encrypt to a recipient the vault could never decrypt with.
|
||||
*
|
||||
* @returns the recipient, or null with the reason already reported
|
||||
*/
|
||||
export function extractAgePublicKey(keyFilePath: string): string | null {
|
||||
export async function extractAgePublicKey(keyFilePath: string): Promise<string | null> {
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await runTool('age-keygen', ['-y', keyFilePath]);
|
||||
const derived = stdout.trim();
|
||||
if (derived.startsWith('age1')) {
|
||||
return derived;
|
||||
}
|
||||
console.error(`❌ ERROR: age-keygen derived no public key from ${keyFilePath}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ToolNotFoundError)) {
|
||||
console.error(`❌ ERROR: ${error instanceof Error ? error.message : error}`);
|
||||
return null;
|
||||
}
|
||||
console.warn(
|
||||
`⚠️ age-keygen is not installed — reading the public key from the comment in ${keyFilePath}, unverified against the secret key.`
|
||||
);
|
||||
}
|
||||
|
||||
return publicKeyFromComment(keyFilePath);
|
||||
}
|
||||
|
||||
/** The `# public key:` line: a claim about the key rather than a derivation from it */
|
||||
function publicKeyFromComment(keyFilePath: string): string | null {
|
||||
try {
|
||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||
|
||||
Reference in New Issue
Block a user