From 78fb480e5b1eba42d8cdbccb9839f7c13e94fdde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Thu, 16 Apr 2026 17:43:51 +0700 Subject: [PATCH 01/46] feat(vc): implement SSH commit signing (ENG-2002) --- src/oclif/commands/signing-key/add.ts | 77 ++++ src/oclif/commands/signing-key/list.ts | 45 +++ src/oclif/commands/signing-key/remove.ts | 35 ++ src/oclif/commands/vc/commit.ts | 56 ++- src/oclif/commands/vc/config.ts | 126 +++++- .../core/interfaces/services/i-git-service.ts | 11 +- .../core/interfaces/services/i-http-client.ts | 8 + .../services/i-signing-key-service.ts | 17 + .../interfaces/vc/i-vc-git-config-store.ts | 6 +- src/server/infra/git/cogit-url.ts | 2 +- .../infra/git/isomorphic-git-service.ts | 16 +- .../infra/http/authenticated-http-client.ts | 24 ++ .../infra/iam/http-signing-key-service.ts | 76 ++++ src/server/infra/process/feature-handlers.ts | 9 + src/server/infra/ssh/index.ts | 5 + src/server/infra/ssh/signing-key-cache.ts | 91 +++++ src/server/infra/ssh/ssh-agent-signer.ts | 267 +++++++++++++ src/server/infra/ssh/ssh-key-parser.ts | 376 ++++++++++++++++++ src/server/infra/ssh/sshsig-signer.ts | 83 ++++ src/server/infra/ssh/types.ts | 37 ++ src/server/infra/transport/handlers/index.ts | 2 + .../transport/handlers/signing-key-handler.ts | 85 ++++ .../infra/transport/handlers/vc-handler.ts | 297 ++++++++++++-- .../infra/vc/file-vc-git-config-store.ts | 7 +- src/shared/transport/events/vc-events.ts | 48 ++- test/unit/infra/ssh/signing-key-cache.test.ts | 150 +++++++ test/unit/infra/ssh/ssh-agent-signer.test.ts | 372 +++++++++++++++++ test/unit/infra/ssh/ssh-key-parser.test.ts | 169 ++++++++ test/unit/infra/ssh/sshsig-signer.test.ts | 79 ++++ .../transport/handlers/vc-handler.test.ts | 60 ++- 30 files changed, 2564 insertions(+), 72 deletions(-) create mode 100644 src/oclif/commands/signing-key/add.ts create mode 100644 src/oclif/commands/signing-key/list.ts create mode 100644 src/oclif/commands/signing-key/remove.ts create mode 100644 src/server/core/interfaces/services/i-signing-key-service.ts create mode 100644 src/server/infra/iam/http-signing-key-service.ts create mode 100644 src/server/infra/ssh/index.ts create mode 100644 src/server/infra/ssh/signing-key-cache.ts create mode 100644 src/server/infra/ssh/ssh-agent-signer.ts create mode 100644 src/server/infra/ssh/ssh-key-parser.ts create mode 100644 src/server/infra/ssh/sshsig-signer.ts create mode 100644 src/server/infra/ssh/types.ts create mode 100644 src/server/infra/transport/handlers/signing-key-handler.ts create mode 100644 test/unit/infra/ssh/signing-key-cache.test.ts create mode 100644 test/unit/infra/ssh/ssh-agent-signer.test.ts create mode 100644 test/unit/infra/ssh/ssh-key-parser.test.ts create mode 100644 test/unit/infra/ssh/sshsig-signer.test.ts diff --git a/src/oclif/commands/signing-key/add.ts b/src/oclif/commands/signing-key/add.ts new file mode 100644 index 000000000..3c0c566f0 --- /dev/null +++ b/src/oclif/commands/signing-key/add.ts @@ -0,0 +1,77 @@ +import {Command, Flags} from '@oclif/core' +import {readFileSync} from 'node:fs' + +import {parseSSHPrivateKey, resolveHome} from '../../../server/infra/ssh/index.js' +import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' +import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class SigningKeyAdd extends Command { + public static description = 'Add an SSH public key to your Byterover account for commit signing' + public static examples = [ + '<%= config.bin %> <%= command.id %> --key ~/.ssh/id_ed25519 --title "Dev laptop"', + '<%= config.bin %> <%= command.id %> -k ~/.ssh/id_ed25519.pub', + ] +public static flags = { + key: Flags.string({ + char: 'k', + description: + 'Path to the SSH private key (used to derive the public key) or a .pub file', + required: true, + }), + title: Flags.string({ + char: 't', + description: 'Human-readable label for the key (defaults to the key comment)', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(SigningKeyAdd) + const keyPath = resolveHome(flags.key) + + let publicKey: string + let {title} = flags + + try { + if (keyPath.endsWith('.pub')) { + // Public key file โ€” read directly + const raw = readFileSync(keyPath, 'utf8').trim() + publicKey = raw + // Extract comment as default title (third field in authorized_keys format) + const parts = raw.split(' ') + if (!title && parts.length >= 3) title = parts.slice(2).join(' ') + } else { + // Private key file โ€” parse to derive public key + const parsed = await parseSSHPrivateKey(keyPath) + // Re-export public key in SSH authorized_keys format: type b64(blob) [comment] + const b64 = parsed.publicKeyBlob.toString('base64') + publicKey = `${parsed.keyType} ${b64}` + if (!title) title = `My ${parsed.keyType} key` + } + } catch (error) { + this.error( + `Failed to read key file: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + if (!title) title = 'My SSH key' + + try { + const response = await withDaemonRetry(async (client) => + client.requestWithAck(VcEvents.SIGNING_KEY, { + action: 'add', + publicKey: publicKey!, + title, + }), + ) + + if (response.action === 'add' && response.key) { + this.log('โœ… Signing key added successfully') + this.log(` Title: ${response.key.title}`) + this.log(` Fingerprint: ${response.key.fingerprint}`) + this.log(` Type: ${response.key.keyType}`) + } + } catch (error) { + this.error(formatConnectionError(error)) + } + } +} diff --git a/src/oclif/commands/signing-key/list.ts b/src/oclif/commands/signing-key/list.ts new file mode 100644 index 000000000..39415a94e --- /dev/null +++ b/src/oclif/commands/signing-key/list.ts @@ -0,0 +1,45 @@ +import {Command} from '@oclif/core' + +import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' +import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class SigningKeyList extends Command { + public static description = 'List SSH signing keys registered on your Byterover account' + public static examples = ['<%= config.bin %> <%= command.id %>'] + + public async run(): Promise { + try { + const response = await withDaemonRetry(async (client) => + client.requestWithAck(VcEvents.SIGNING_KEY, {action: 'list'}), + ) + + if (response.action !== 'list' || !response.keys) { + this.error('Unexpected response from daemon') + } + + const {keys} = response + + if (keys.length === 0) { + this.log('No signing keys registered.') + this.log(' Run: brv signing-key add --key ~/.ssh/id_ed25519') + return + } + + this.log(`\nSigning keys (${keys.length}):\n`) + for (const key of keys) { + const lastUsed = key.lastUsedAt + ? `Last used: ${new Date(key.lastUsedAt).toLocaleDateString()}` + : 'Never used' + this.log(` [${key.id}]`) + this.log(` Title: ${key.title}`) + this.log(` Fingerprint: ${key.fingerprint}`) + this.log(` Type: ${key.keyType}`) + this.log(` ${lastUsed}`) + this.log(` Added: ${new Date(key.createdAt).toLocaleDateString()}`) + this.log('') + } + } catch (error) { + this.error(formatConnectionError(error)) + } + } +} diff --git a/src/oclif/commands/signing-key/remove.ts b/src/oclif/commands/signing-key/remove.ts new file mode 100644 index 000000000..6c191c633 --- /dev/null +++ b/src/oclif/commands/signing-key/remove.ts @@ -0,0 +1,35 @@ +import {Args, Command} from '@oclif/core' + +import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' +import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' + +export default class SigningKeyRemove extends Command { + public static args = { + id: Args.string({ + description: 'Key ID to remove (from brv signing-key list)', + required: true, + }), + } + public static description = 'Remove an SSH signing key from your Byterover account' +public static examples = [ + '<%= config.bin %> <%= command.id %> ', + '# Get key ID from: brv signing-key list', + ] + + public async run(): Promise { + const {args} = await this.parse(SigningKeyRemove) + + try { + await withDaemonRetry(async (client) => + client.requestWithAck(VcEvents.SIGNING_KEY, { + action: 'remove', + keyId: args.id, + }), + ) + + this.log(`โœ… Signing key removed: ${args.id}`) + } catch (error) { + this.error(formatConnectionError(error)) + } + } +} diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index 3977a2308..e45a41e17 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -1,18 +1,28 @@ +import {input} from '@inquirer/prompts' import {Command, Flags} from '@oclif/core' -import {type IVcCommitResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' +import {type IVcCommitRequest, type IVcCommitResponse, VcErrorCode, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' export default class VcCommit extends Command { public static description = 'Save staged changes as a commit' - public static examples = ['<%= config.bin %> <%= command.id %> -m "Add project architecture notes"'] - public static flags = { + public static examples = [ + '<%= config.bin %> <%= command.id %> -m "Add project architecture notes"', + '<%= config.bin %> <%= command.id %> -m "Signed commit" --sign', + '<%= config.bin %> <%= command.id %> -m "Unsigned commit" --no-sign', + ] +public static flags = { message: Flags.string({ char: 'm', description: 'Commit message', }), + sign: Flags.boolean({ + allowNo: true, + description: 'Sign the commit with your configured SSH key. Use --no-sign to override commit.sign=true.', + }), } - public static strict = false +private static readonly MAX_PASSPHRASE_RETRIES = 3 +public static strict = false public async run(): Promise { const {argv, flags} = await this.parse(VcCommit) @@ -26,13 +36,47 @@ export default class VcCommit extends Command { this.error('Usage: brv vc commit -m ""') } + const {sign} = flags + + await this.runCommit(message, sign) + } + + private async runCommit(message: string, sign: boolean | undefined, passphrase?: string, attempt: number = 0): Promise { + const payload: IVcCommitRequest = {message, ...(sign === undefined ? {} : {sign}), ...(passphrase ? {passphrase} : {})} + try { const result = await withDaemonRetry(async (client) => - client.requestWithAck(VcEvents.COMMIT, {message}), + client.requestWithAck(VcEvents.COMMIT, payload), ) - this.log(`[${result.sha.slice(0, 7)}] ${result.message}`) + const sigIndicator = sign === true ? ' ๐Ÿ”' : '' + this.log(`[${result.sha.slice(0, 7)}] ${result.message}${sigIndicator}`) } catch (error) { + // Passphrase required โ€” prompt and retry (capped) + if ( + error instanceof Error && + 'code' in error && + (error as {code: string}).code === VcErrorCode.PASSPHRASE_REQUIRED + ) { + if (attempt >= VcCommit.MAX_PASSPHRASE_RETRIES) { + this.error(`Too many failed passphrase attempts (${VcCommit.MAX_PASSPHRASE_RETRIES}).`) + } + + let pp: string + try { + pp = await input({ + message: 'Enter SSH key passphrase:', + // @ts-expect-error โ€” inquirer types vary; hide input for passwords + type: 'password', + }) + } catch { + this.error('Passphrase input cancelled.') + } + + await this.runCommit(message, sign, pp!, attempt + 1) + return + } + this.error(formatConnectionError(error)) } } diff --git a/src/oclif/commands/vc/config.ts b/src/oclif/commands/vc/config.ts index 738350f52..ed50a6adc 100644 --- a/src/oclif/commands/vc/config.ts +++ b/src/oclif/commands/vc/config.ts @@ -1,27 +1,58 @@ -import {Args, Command} from '@oclif/core' +import {Args, Command, Flags} from '@oclif/core' +import {existsSync, readFileSync} from 'node:fs' -import {isVcConfigKey, type IVcConfigResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' +import {parseSSHPrivateKey, resolveHome} from '../../../server/infra/ssh/index.js' +import {isVcConfigKey, type IVcConfigResponse, type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' export default class VcConfig extends Command { public static args = { - key: Args.string({description: 'Config key (user.name or user.email)', required: true}), + key: Args.string({ + description: 'Config key: user.name, user.email, user.signingkey, commit.sign', + required: false, + }), value: Args.string({description: 'Value to set (omit to read current value)'}), } - public static description = 'Get or set commit author for ByteRover version control' - public static examples = [ +public static description = 'Get or set commit author / signing config for ByteRover version control' +public static examples = [ '<%= config.bin %> <%= command.id %> user.name "Your Name"', '<%= config.bin %> <%= command.id %> user.email "you@example.com"', + '<%= config.bin %> <%= command.id %> user.signingkey ~/.ssh/id_ed25519', + '<%= config.bin %> <%= command.id %> commit.sign true', '<%= config.bin %> <%= command.id %> user.name', - '<%= config.bin %> <%= command.id %> user.email', + '<%= config.bin %> <%= command.id %> --import-git-signing', ] +public static flags = { + 'import-git-signing': Flags.boolean({ + description: + 'Import SSH signing config from local or global git config ' + + '(user.signingKey + gpg.format=ssh + commit.gpgSign)', + exclusive: ['key'], + }), + } public async run(): Promise { - const {args} = await this.parse(VcConfig) + const {args, flags} = await this.parse(VcConfig) const {key, value} = args + // --import-git-signing mode: reads local/global gitconfig and imports into brv config + if (flags['import-git-signing']) { + await this.runImport() + return + } + + if (!key) { + this.error( + 'Usage: brv vc config [value]\n' + + 'Keys: user.name, user.email, user.signingkey, commit.sign\n' + + 'Or: brv vc config --import-git-signing', + ) + } + if (!isVcConfigKey(key)) { - this.error(`Unknown key '${key}'. Allowed: user.name, user.email.`) + this.error( + `Unknown key '${key}'. Allowed: user.name, user.email, user.signingkey, commit.sign.`, + ) } try { @@ -29,8 +60,85 @@ export default class VcConfig extends Command { client.requestWithAck(VcEvents.CONFIG, {key, value}), ) - this.log(`${result.value}`) + if (result.hint) this.log(` Hint: ${result.hint}`) + this.log(result.value) + } catch (error) { + this.error(formatConnectionError(error)) + } + } + + /** + * Import SSH signing configuration from the local or global git config. + * Reads: user.signingKey, gpg.format, commit.gpgSign via `git config --get` + */ + private async runImport(): Promise { + try { + const result = await withDaemonRetry(async (client) => + client.requestWithAck(VcEvents.CONFIG, {importGitSigning: true, key: 'user.signingkey'}), + ) + + if (result.hint) this.log(` ${result.hint}`) + this.log(`โœ… Imported signing config from git:`) + this.log(` user.signingkey = ${result.value}`) + + // Attempt to register the key automatically + this.log(`\nโณ Attempting to register signing key with ByteRover server...`) + try { + const keyPath = resolveHome(result.value) + let publicKey: string + let title = 'My SSH key' + + const pubPath = keyPath.endsWith('.pub') ? keyPath : `${keyPath}.pub` + + if (existsSync(pubPath)) { + const raw = readFileSync(pubPath, 'utf8').trim() + publicKey = raw + const parts = raw.split(' ') + if (parts.length >= 3) title = parts.slice(2).join(' ') + } else { + const parsed = await parseSSHPrivateKey(keyPath) + const b64 = parsed.publicKeyBlob.toString('base64') + publicKey = `${parsed.keyType} ${b64}` + title = `My ${parsed.keyType} key` + } + + const response = await withDaemonRetry(async (client) => + client.requestWithAck(VcEvents.SIGNING_KEY, { + action: 'add', + publicKey, + title, + }), + ) + + if (response.action === 'add' && response.key) { + this.log('โœ… Signing key registered successfully on server') + } + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error) + if (errMsg.includes('already exists') || errMsg.includes('Duplicate') || errMsg.includes('ALREADY_EXISTS') || errMsg.includes('409')) { + this.log('โœ… Signing key is already registered on server') + } else { + this.log(`โš ๏ธ Could not automatically register key: ${errMsg}`) + this.log(` You may need to run: brv signing-key add --key ${result.value}`) + } + } } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + // Provide helpful guidance when no signing key is configured in git + if (msg.includes('not set') || msg.includes('not found')) { + this.log('โ„น๏ธ No SSH signing key found in your git config.') + this.log('') + this.log('To configure SSH signing in git:') + this.log(' git config user.signingKey ~/.ssh/id_ed25519') + this.log(' git config gpg.format ssh') + this.log(' git config commit.gpgSign true') + this.log('') + this.log('Or set it directly in brv:') + this.log(' brv vc config user.signingkey ~/.ssh/id_ed25519') + this.log(' brv vc config commit.sign true') + return + } + this.error(formatConnectionError(error)) } } diff --git a/src/server/core/interfaces/services/i-git-service.ts b/src/server/core/interfaces/services/i-git-service.ts index 29e9b5169..e11c88da6 100644 --- a/src/server/core/interfaces/services/i-git-service.ts +++ b/src/server/core/interfaces/services/i-git-service.ts @@ -54,7 +54,16 @@ export type AheadBehind = {ahead: number; behind: number} // --- Params types --- export type InitGitParams = BaseGitParams & {defaultBranch?: string} export type AddGitParams = BaseGitParams & {filePaths: string[]} -export type CommitGitParams = BaseGitParams & {author?: {email: string; name: string}; message: string} +export type CommitGitParams = { + author?: {email: string; name: string} + message: string + /** + * Optional sign callback โ€” receives the raw commit payload (the text that would be signed) + * and must return an armored SSH signature (-----BEGIN SSH SIGNATURE----- ... -----END SSH SIGNATURE-----). + * When provided, the commit is created with a gpgsig header containing the returned signature. + */ + onSign?: (payload: string) => Promise +} & BaseGitParams export type LogGitParams = BaseGitParams & {depth?: number; ref?: string} export type PushGitParams = BaseGitParams & {branch?: string; remote?: string} export type PullGitParams = BaseGitParams & {allowUnrelatedHistories?: boolean; author?: {email: string; name: string}; branch?: string; remote?: string} diff --git a/src/server/core/interfaces/services/i-http-client.ts b/src/server/core/interfaces/services/i-http-client.ts index d8e79ab6c..9e35ecadf 100644 --- a/src/server/core/interfaces/services/i-http-client.ts +++ b/src/server/core/interfaces/services/i-http-client.ts @@ -16,6 +16,14 @@ export type HttpRequestConfig = { * - Response parsing */ export interface IHttpClient { + /** + * Performs an HTTP DELETE request. + * @param url The URL to request + * @param config Optional request configuration (headers, timeout) + * @returns A promise that resolves to the response data + */ + delete: (url: string, config?: HttpRequestConfig) => Promise + /** * Performs an HTTP GET request. * @param url The URL to request diff --git a/src/server/core/interfaces/services/i-signing-key-service.ts b/src/server/core/interfaces/services/i-signing-key-service.ts new file mode 100644 index 000000000..a1be67a67 --- /dev/null +++ b/src/server/core/interfaces/services/i-signing-key-service.ts @@ -0,0 +1,17 @@ +// โ”€โ”€ DTOs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface SigningKeyResource { + createdAt: string + fingerprint: string + id: string + keyType: string + lastUsedAt?: string + publicKey: string + title: string +} + +export interface ISigningKeyService { + addKey(title: string, publicKey: string): Promise + listKeys(): Promise + removeKey(keyId: string): Promise +} diff --git a/src/server/core/interfaces/vc/i-vc-git-config-store.ts b/src/server/core/interfaces/vc/i-vc-git-config-store.ts index 845384122..a07af5b76 100644 --- a/src/server/core/interfaces/vc/i-vc-git-config-store.ts +++ b/src/server/core/interfaces/vc/i-vc-git-config-store.ts @@ -1,7 +1,11 @@ -// Both optional โ€” user sets name/email separately, one at a time (like git config) +// Both optional โ€” user sets fields separately, one at a time (like git config) export interface IVcGitConfig { + /** Auto-sign all commits when true (default: false) */ + commitSign?: boolean email?: string name?: string + /** Path to SSH private key for signing (e.g., "~/.ssh/id_ed25519") */ + signingKey?: string } export interface IVcGitConfigStore { diff --git a/src/server/infra/git/cogit-url.ts b/src/server/infra/git/cogit-url.ts index 7008f5803..306e24518 100644 --- a/src/server/infra/git/cogit-url.ts +++ b/src/server/infra/git/cogit-url.ts @@ -14,7 +14,7 @@ export function buildCogitRemoteUrl(baseUrl: string, teamName: string, spaceName export function parseUserFacingUrl(url: string): null | {spaceName: string; teamName: string} { try { const parsed = new URL(url) - const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+?)\.git$/) + const match = parsed.pathname.match(/\/([^/]+)\/([^/]+?)\.git$/) if (!match) return null return {spaceName: match[2], teamName: match[1]} } catch { diff --git a/src/server/infra/git/isomorphic-git-service.ts b/src/server/infra/git/isomorphic-git-service.ts index 78639dc21..82fcd98aa 100644 --- a/src/server/infra/git/isomorphic-git-service.ts +++ b/src/server/infra/git/isomorphic-git-service.ts @@ -245,7 +245,21 @@ export class IsomorphicGitService implements IGitService { let sha: string try { - sha = await git.commit({author, dir, fs, message: params.message, ...(parent ? {parent} : {})}) + sha = await git.commit({ + author, + dir, + fs, + message: params.message, + ...(parent ? {parent} : {}), + ...(params.onSign + ? { + onSign: async ({payload}: {payload: string}) => ({ + signature: await params.onSign!(payload), + }), + signingKey: 'ssh-signing', + } + : {}), + }) } catch (error) { if (error instanceof git.Errors.UnmergedPathsError) { const paths = error.data.filepaths.join(', ') diff --git a/src/server/infra/http/authenticated-http-client.ts b/src/server/infra/http/authenticated-http-client.ts index a08aae7b7..9cf8f8281 100644 --- a/src/server/infra/http/authenticated-http-client.ts +++ b/src/server/infra/http/authenticated-http-client.ts @@ -49,6 +49,30 @@ export class AuthenticatedHttpClient implements IHttpClient { this.sessionKey = sessionKey } + /** + * Performs an HTTP DELETE request with authentication headers. + * @param url The URL to request + * @param config Optional request configuration (headers, timeout) + * @returns A promise that resolves to the response data + * @throws Error if the request fails + */ + public async delete(url: string, config?: HttpRequestConfig): Promise { + try { + const axiosConfig: AxiosRequestConfig = { + headers: this.buildHeaders(config?.headers), + httpAgent: ProxyConfig.getProxyAgent(), + httpsAgent: ProxyConfig.getProxyAgent(), + proxy: false, + timeout: config?.timeout, + } + + const response = await axios.delete(url, axiosConfig) + return response.data + } catch (error) { + throw this.handleError(error) + } + } + /** * Performs an HTTP GET request with authentication headers. * @param url The URL to request diff --git a/src/server/infra/iam/http-signing-key-service.ts b/src/server/infra/iam/http-signing-key-service.ts new file mode 100644 index 000000000..656edd0c5 --- /dev/null +++ b/src/server/infra/iam/http-signing-key-service.ts @@ -0,0 +1,76 @@ +import type {IHttpClient} from '../../core/interfaces/services/i-http-client.js' +import type {ISigningKeyService, SigningKeyResource} from '../../core/interfaces/services/i-signing-key-service.js' + +// IAM API wraps all responses in {success, data} +interface ApiEnvelope { + data: T + success: boolean +} + +// IAM API response wrappers (inside data envelope) +interface CreateSigningKeyData { + signing_key: RawSigningKeyResource +} + +interface ListSigningKeysData { + signing_keys: RawSigningKeyResource[] +} + +// IAM returns snake_case; map to camelCase +interface RawSigningKeyResource { + created_at: string + fingerprint: string + id: string + key_type: string + last_used_at?: string + public_key: string + title: string +} + +function mapResource(raw: RawSigningKeyResource): SigningKeyResource { + return { + createdAt: raw.created_at, + fingerprint: raw.fingerprint, + id: raw.id, + keyType: raw.key_type, + lastUsedAt: raw.last_used_at, + publicKey: raw.public_key, + title: raw.title, + } +} + +/** + * HTTP client for the IAM signing key CRUD API. + * + * API base: /api/v3/users/me/signing-keys (configured via BRV_API_BASE_URL / httpClient base URL) + */ +export class HttpSigningKeyService implements ISigningKeyService { + private readonly httpClient: IHttpClient + private readonly iamBaseUrl: string + + constructor(httpClient: IHttpClient, iamBaseUrl: string) { + this.httpClient = httpClient + this.iamBaseUrl = iamBaseUrl.replace(/\/$/, '') + } + + async addKey(title: string, publicKey: string): Promise { + const response = await this.httpClient.post>( + `${this.iamBaseUrl}/api/v3/users/me/signing-keys`, + /* eslint-disable camelcase */ + {public_key: publicKey, title}, + /* eslint-enable camelcase */ + ) + return mapResource(response.data.signing_key) + } + + async listKeys(): Promise { + const response = await this.httpClient.get>( + `${this.iamBaseUrl}/api/v3/users/me/signing-keys`, + ) + return (response.data.signing_keys ?? []).map((raw) => mapResource(raw)) + } + + async removeKey(keyId: string): Promise { + await this.httpClient.delete(`${this.iamBaseUrl}/api/v3/users/me/signing-keys/${keyId}`) + } +} diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index fdbd59648..e9749b2d2 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -61,6 +61,7 @@ import { PushHandler, ResetHandler, ReviewHandler, + SigningKeyHandler, SourceHandler, SpaceHandler, StatusHandler, @@ -305,6 +306,14 @@ export async function setupFeatureHandlers({ webAppUrl: envConfig.webAppUrl, }).setup() + // Signing Key handler โ€” creates a fresh authenticated HTTP client per request + // using the current session key from tokenStore (consistent with PushHandler pattern). + new SigningKeyHandler({ + iamBaseUrl: envConfig.iamBaseUrl, + tokenStore, + transport, + }).setup() + // Worktree & source handlers new WorktreeHandler({resolveProjectPath, transport}).setup() new SourceHandler({resolveProjectPath, transport}).setup() diff --git a/src/server/infra/ssh/index.ts b/src/server/infra/ssh/index.ts new file mode 100644 index 000000000..0d8603c45 --- /dev/null +++ b/src/server/infra/ssh/index.ts @@ -0,0 +1,5 @@ +export {SigningKeyCache} from './signing-key-cache.js' +export {SshAgentSigner, tryGetSshAgentSigner} from './ssh-agent-signer.js' +export {parseSSHPrivateKey, probeSSHKey, resolveHome} from './ssh-key-parser.js' +export {signCommitPayload} from './sshsig-signer.js' +export type {ParsedSSHKey, SSHKeyProbe, SSHKeyType, SSHSignatureResult} from './types.js' diff --git a/src/server/infra/ssh/signing-key-cache.ts b/src/server/infra/ssh/signing-key-cache.ts new file mode 100644 index 000000000..795082546 --- /dev/null +++ b/src/server/infra/ssh/signing-key-cache.ts @@ -0,0 +1,91 @@ +import type {ParsedSSHKey} from './types.js' + +interface CacheEntry { + expiresAt: number + key: ParsedSSHKey +} + +/** + * In-memory TTL cache for parsed SSH private keys. + * + * Option C Path B: after successful file-based parsing (with passphrase), + * cache the ParsedSSHKey object (which holds an opaque crypto.KeyObject) + * so subsequent commits within the TTL window require no passphrase prompt. + * + * Security properties: + * - Stored in daemon process memory only โ€” never written to disk + * - crypto.KeyObject is opaque and not directly extractable + * - Passphrase is never stored โ€” only the decrypted key object + * - Cleared entirely on daemon restart + * - Per-key invalidation when user changes config + */ +export class SigningKeyCache { + private readonly cache = new Map() + private readonly ttlMs: number + + constructor(ttlMs: number = 30 * 60 * 1000) { + this.ttlMs = ttlMs + } + + /** + * Current number of cached (non-expired) keys. + */ + get size(): number { + const now = Date.now() + let count = 0 + for (const [, entry] of this.cache) { + if (now <= entry.expiresAt) count++ + } + + return count + } + + /** + * Get a cached key by its resolved file path. + * Returns null if the entry does not exist or has expired. + */ + get(keyPath: string): null | ParsedSSHKey { + const entry = this.cache.get(keyPath) + if (!entry) return null + + if (Date.now() > entry.expiresAt) { + this.cache.delete(keyPath) + return null + } + + return entry.key + } + + /** + * Invalidate a specific key (e.g., when user changes signing key config). + */ + invalidate(keyPath: string): void { + this.cache.delete(keyPath) + } + + /** + * Clear all cached keys (e.g., on explicit logout or security reset). + */ + invalidateAll(): void { + this.cache.clear() + } + + /** + * Cache a parsed key by its resolved file path. + * Resets TTL if the key was already cached. + * Also sweeps expired entries to prevent memory leaks. + */ + set(keyPath: string, key: ParsedSSHKey): void { + const now = Date.now() + + // Sweep expired entries + for (const [path, entry] of this.cache) { + if (now > entry.expiresAt) this.cache.delete(path) + } + + this.cache.set(keyPath, { + expiresAt: now + this.ttlMs, + key, + }) + } +} diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts new file mode 100644 index 000000000..c1858d97b --- /dev/null +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -0,0 +1,267 @@ +import {createHash} from 'node:crypto' +import net from 'node:net' + +import type {SSHKeyType, SSHSignatureResult} from './types.js' + +import {getPublicKeyMetadata} from './ssh-key-parser.js' + +// SSH agent protocol message types +const SSH_AGENTC_REQUEST_IDENTITIES = 11 +const SSH_AGENTC_SIGN_REQUEST = 13 +const SSH_AGENT_IDENTITIES_ANSWER = 12 +const SSH_AGENT_SIGN_RESPONSE = 14 +const SSH_AGENT_FAILURE = 5 + +// SSH agent sign flags +const SSH_AGENT_RSA_SHA2_512 = 4 + +/** + * Read a uint32 big-endian from a buffer at offset. + */ +function readUInt32(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset) +} + +/** + * Write a uint32 big-endian prefix then data (SSH wire string). + */ +function sshString(data: Buffer | string): Buffer { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8') + const len = Buffer.allocUnsafe(4) + len.writeUInt32BE(buf.length, 0) + return Buffer.concat([len, buf]) +} + +/** + * Low-level SSH agent client over a Unix domain socket. + */ +class SshAgentClient { + private readonly socketPath: string + + constructor(socketPath: string) { + this.socketPath = socketPath + } + + /** List all identities (public keys) currently held by the agent. */ + async listIdentities(): Promise> { + const request = Buffer.from([SSH_AGENTC_REQUEST_IDENTITIES]) + const response = await this.request(request) + + if (response[0] !== SSH_AGENT_IDENTITIES_ANSWER) { + throw new Error(`Agent returned unexpected message type: ${response[0]}`) + } + + const count = readUInt32(response, 1) + const identities: Array<{blob: Buffer; comment: string; fingerprint: string}> = [] + let offset = 5 + + for (let i = 0; i < count; i++) { + const blobLen = readUInt32(response, offset) + offset += 4 + const blob = response.slice(offset, offset + blobLen) + offset += blobLen + + const commentLen = readUInt32(response, offset) + offset += 4 + const comment = response.slice(offset, offset + commentLen).toString('utf8') + offset += commentLen + + // Compute SHA256 fingerprint to match against our key + const hash = createHash('sha256').update(blob).digest('base64').replace(/=+$/, '') + const fingerprint = `SHA256:${hash}` + + identities.push({blob, comment, fingerprint}) + } + + return identities + } + + /** Send a request to the agent and receive the full response. */ + async request(payload: Buffer): Promise { + const MAX_RESPONSE_SIZE = 1024 * 1024 // 1 MB + + return new Promise((resolve, reject) => { + const socket = net.createConnection(this.socketPath) + const chunks: Buffer[] = [] + let settled = false + + const settle = (fn: () => void): void => { + if (settled) return + settled = true + socket.destroy() + fn() + } + + socket.once('connect', () => { + // Prefix payload with uint32 length + const lenBuf = Buffer.allocUnsafe(4) + lenBuf.writeUInt32BE(payload.length, 0) + socket.write(Buffer.concat([lenBuf, payload])) + }) + + socket.on('data', (chunk: Buffer) => { + chunks.push(chunk) + const accumulated = Buffer.concat(chunks) + + if (accumulated.length >= 4) { + const responseLen = readUInt32(accumulated, 0) + + if (responseLen > MAX_RESPONSE_SIZE) { + settle(() => reject(new Error(`Agent response too large: ${responseLen} bytes`))) + return + } + + if (accumulated.length >= 4 + responseLen) { + settle(() => resolve(accumulated.slice(4, 4 + responseLen))) + } + } + }) + + socket.once('error', (err) => settle(() => reject(err))) + socket.once('close', () => { + settle(() => reject(new Error('Agent socket closed without response'))) + }) + + // Timeout after 3 seconds + socket.setTimeout(3000, () => { + settle(() => reject(new Error('SSH agent request timed out'))) + }) + }) + } + + /** Request the agent to sign data with a specific key blob. */ + async sign(keyBlob: Buffer, data: Buffer, flags: number = 0): Promise { + const request = Buffer.concat([ + Buffer.from([SSH_AGENTC_SIGN_REQUEST]), + sshString(keyBlob), + sshString(data), + (() => { + const f = Buffer.allocUnsafe(4) + f.writeUInt32BE(flags, 0) + return f + })(), + ]) + + const response = await this.request(request) + + if (response[0] === SSH_AGENT_FAILURE) { + throw new Error('SSH agent refused to sign (key may not be loaded)') + } + + if (response[0] !== SSH_AGENT_SIGN_RESPONSE) { + throw new Error(`Agent returned unexpected sign response type: ${response[0]}`) + } + + // Response: byte SSH_AGENT_SIGN_RESPONSE + string(signature) + const sigLen = readUInt32(response, 1) + return response.slice(5, 5 + sigLen) + } +} + +/** + * High-level signer that uses ssh-agent to produce sshsig-format signatures. + */ +export class SshAgentSigner { + private readonly agent: SshAgentClient + private readonly keyBlob: Buffer + private readonly keyType: string + + constructor(agent: SshAgentClient, keyBlob: Buffer, keyType: string) { + this.agent = agent + this.keyBlob = keyBlob + this.keyType = keyType + } + + /** + * Sign a commit payload using the ssh-agent, producing an armored sshsig signature. + */ + async sign(payload: string): Promise { + const {createHash} = await import('node:crypto') + + // Envelope magic: 6 bytes, no null (per PROTOCOL.sshsig blob format) + const SSHSIG_MAGIC = Buffer.from('SSHSIG') + // Signed data magic: 7 bytes, WITH null (per PROTOCOL.sshsig ยง2 signed data) + const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG\0') + const NAMESPACE = 'git' + const HASH_ALGORITHM = 'sha512' + + // 1. Hash commit payload + const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() + + // 2. Build signed data (uses 7-byte magic WITH null terminator) + const signedData = Buffer.concat([ + SSHSIG_SIGNED_DATA_MAGIC, + sshString(NAMESPACE), + sshString(''), + sshString(HASH_ALGORITHM), + sshString(messageHash), + ]) + + // 3. Choose sign flags + const flags = this.keyType === 'ssh-rsa' ? SSH_AGENT_RSA_SHA2_512 : 0 + + // 4. Ask agent to sign the signed data blob + const agentSignature = await this.agent.sign(this.keyBlob, signedData, flags) + + // 5. The agent returns a full SSH signature blob already + // (string(key-type) + string(raw-sig)) + // We use it directly as the sshsig signature field + + // 6. Build sshsig binary envelope + const versionBuf = Buffer.allocUnsafe(4) + versionBuf.writeUInt32BE(1, 0) + + const sshsigBinary = Buffer.concat([ + SSHSIG_MAGIC, + versionBuf, + sshString(this.keyBlob), + sshString(NAMESPACE), + sshString(''), + sshString(HASH_ALGORITHM), + sshString(agentSignature), + ]) + + // 7. Armor + const base64 = sshsigBinary.toString('base64') + const lines = base64.match(/.{1,76}/g) ?? [base64] + const armored = [ + '-----BEGIN SSH SIGNATURE-----', + ...lines, + '-----END SSH SIGNATURE-----', + ].join('\n') + + return {armored, raw: sshsigBinary} + } +} + +/** + * Try to get an SshAgentSigner for the key at the given path. + * + * Priority chain path A: connect to ssh-agent โ†’ find matching key โ†’ return signer. + * Returns null (non-throwing) if: + * - $SSH_AUTH_SOCK is not set + * - Agent is unreachable + * - The key at keyPath is not loaded in the agent + */ +export async function tryGetSshAgentSigner(keyPath: string): Promise { + const agentSocket = process.env.SSH_AUTH_SOCK + if (!agentSocket) return null + + try { + const agent = new SshAgentClient(agentSocket) + + // Derive public key fingerprint from key file (reads only public key โ€” no passphrase needed) + const parsed = await getPublicKeyMetadata(keyPath).catch(() => null) + if (!parsed) return null + + // Find matching identity in agent + const identities = await agent.listIdentities() + const match = identities.find((id) => id.fingerprint === parsed.fingerprint) + if (!match) return null + + return new SshAgentSigner(agent, match.blob, parsed.keyType as SSHKeyType) + } catch { + // Agent unavailable โ€” degrade gracefully to cache/file path + return null + } +} diff --git a/src/server/infra/ssh/ssh-key-parser.ts b/src/server/infra/ssh/ssh-key-parser.ts new file mode 100644 index 000000000..e28ea1527 --- /dev/null +++ b/src/server/infra/ssh/ssh-key-parser.ts @@ -0,0 +1,376 @@ +import {createHash, createPrivateKey, createPublicKey} from 'node:crypto' +import {constants} from 'node:fs' +import {access, readFile} from 'node:fs/promises' +import {homedir} from 'node:os' + +import type {ParsedSSHKey, SSHKeyProbe, SSHKeyType} from './types.js' + +// โ”€โ”€ OpenSSH private key format parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Spec: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key +// +// Binary layout: +// "openssh-key-v1\0" (magic) +// string ciphername ("none" if unencrypted) +// string kdfname ("none" if unencrypted) +// string kdfoptions (empty if unencrypted) +// uint32 nkeys (number of keys, usually 1) +// string pubkey (SSH wire-format public key) +// string private_keys (encrypted or plaintext private key data) +// +// Private key data (plaintext, nkeys=1): +// uint32 check1 +// uint32 check2 (must equal check1) +// string keytype (e.g., "ssh-ed25519") +// [key-type-specific private key fields] +// string comment +// [padding bytes: 1,2,3,...] + +const OPENSSH_MAGIC = 'openssh-key-v1\0' + +const VALID_SSH_KEY_TYPES: ReadonlySet = new Set([ + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'ssh-ed25519', + 'ssh-rsa', +]) + +/** Read a uint32 big-endian from a buffer at offset; returns [value, newOffset] */ +function readUInt32(buf: Buffer, offset: number): [number, number] { + return [buf.readUInt32BE(offset), offset + 4] +} + +/** Read an SSH wire-format length-prefixed string; returns [Buffer, newOffset] */ +function readSSHString(buf: Buffer, offset: number): [Buffer, number] { + const [len, afterLen] = readUInt32(buf, offset) + return [buf.subarray(afterLen, afterLen + len), afterLen + len] +} + +/** Parse the binary OpenSSH private key format (unencrypted only). */ +function parseOpenSSHKey(raw: string): { + cipherName: string + keyType: SSHKeyType + privateKeyBlob: Buffer + publicKeyBlob: Buffer +} { + // Strip PEM armor + const b64 = raw + .replace('-----BEGIN OPENSSH PRIVATE KEY-----', '') + .replace('-----END OPENSSH PRIVATE KEY-----', '') + .replaceAll(/\s+/g, '') + const buf = Buffer.from(b64, 'base64') + + // Verify magic + const magic = buf.subarray(0, OPENSSH_MAGIC.length).toString() + if (magic !== OPENSSH_MAGIC) { + throw new Error('Not an OpenSSH private key (wrong magic bytes)') + } + + let offset = OPENSSH_MAGIC.length + + // ciphername + let cipherNameBuf: Buffer + ;[cipherNameBuf, offset] = readSSHString(buf, offset) + const cipherName = cipherNameBuf.toString() + + // kdfname (skip value โ€” only offset matters) + ;[, offset] = readSSHString(buf, offset) + + // kdfoptions (skip value) + ;[, offset] = readSSHString(buf, offset) + + // nkeys + let nkeys: number + ;[nkeys, offset] = readUInt32(buf, offset) + if (nkeys !== 1) { + throw new Error(`OpenSSH key file contains ${nkeys} keys; only single-key files are supported`) + } + + // public key blob (SSH wire format) + let publicKeyBlob: Buffer + ;[publicKeyBlob, offset] = readSSHString(buf, offset) + + // private key blob (may be encrypted) + let privateKeyBlob: Buffer + ;[privateKeyBlob, offset] = readSSHString(buf, offset) + + // Read key type from public key blob to identify the key + const [keyTypeBuf] = readSSHString(publicKeyBlob, 0) + const keyTypeStr = keyTypeBuf.toString() + if (!VALID_SSH_KEY_TYPES.has(keyTypeStr)) { + throw new Error(`Unknown SSH key type: '${keyTypeStr}'`) + } + + const keyType = keyTypeStr as SSHKeyType + + return {cipherName, keyType, privateKeyBlob, publicKeyBlob} +} + +/** + * Convert an OpenSSH Ed25519 private key blob to a Node.js-loadable format. + * + * Ed25519 private key blob layout (plaintext): + * uint32 check1 + * uint32 check2 + * string "ssh-ed25519" + * string pubkey (32 bytes) + * string privkey (64 bytes: 32-byte seed + 32-byte pubkey) + * string comment + * padding bytes + */ +function opensshEd25519ToNodeKey(privateKeyBlob: Buffer): { + privateKeyPkcs8: Buffer + publicKeyBlob: Buffer +} { + let offset = 0 + + // check1 and check2 must match (used to verify decryption) + const [check1] = readUInt32(privateKeyBlob, offset) + offset += 4 + const [check2] = readUInt32(privateKeyBlob, offset) + offset += 4 + + if (check1 !== check2) { + throw new Error('OpenSSH key decryption check failed (wrong passphrase?)') + } + + // key type + let keyTypeBuf: Buffer + ;[keyTypeBuf, offset] = readSSHString(privateKeyBlob, offset) + const keyType = keyTypeBuf.toString() + + if (keyType !== 'ssh-ed25519') { + throw new Error(`Expected ssh-ed25519 key type, got: ${keyType}`) + } + + // public key (32 bytes) + let pubKeyBytes: Buffer + ;[pubKeyBytes, offset] = readSSHString(privateKeyBlob, offset) + + // private key (64 bytes: first 32 = seed) + let privKeyBytes: Buffer + ;[privKeyBytes, offset] = readSSHString(privateKeyBlob, offset) + + // The Ed25519 "private key" in OpenSSH format is the 64-byte concatenation + // of: seed (32 bytes) + public key (32 bytes). + // Node.js needs a DER-encoded ASN.1 PKCS8 structure for Ed25519. + // + // PKCS8 for Ed25519: + // SEQUENCE { + // INTEGER 0 (version) + // SEQUENCE { OID 1.3.101.112 (id-EdDSA) } + // OCTET STRING wrapping OCTET STRING (32-byte seed) + // } + const seed = privKeyBytes.subarray(0, 32) + + // ASN.1 encoding for Ed25519 private key in PKCS8 format + // This is the known fixed ASN.1 header for Ed25519 PKCS8 + const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex') + const pkcs8Der = Buffer.concat([pkcs8Header, seed]) + + // SSH wire format public key blob: string("ssh-ed25519") + string(32-byte-pubkey) + function sshStr(data: Buffer | string): Buffer { + const b = Buffer.isBuffer(data) ? data : Buffer.from(data) + const len = Buffer.allocUnsafe(4) + len.writeUInt32BE(b.length, 0) + return Buffer.concat([len, b]) + } + + const publicKeyBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(pubKeyBytes)]) + + return {privateKeyPkcs8: pkcs8Der, publicKeyBlob} +} + +/** Detect whether a PEM key parsing error indicates an encrypted key needing a passphrase. */ +function isPassphraseError(err: unknown): boolean { + if (!(err instanceof Error)) return false + + // Node.js crypto errors expose an `code` property (e.g., 'ERR_OSSL_BAD_DECRYPT') + const code = 'code' in err && typeof (err as {code: unknown}).code === 'string' + ? (err as {code: string}).code + : '' + if (code.includes('ERR_OSSL') && code.includes('DECRYPT')) return true + + // Fallback: string matching for compatibility across Node.js/OpenSSL versions + const msg = err.message.toLowerCase() + return msg.includes('bad decrypt') || msg.includes('passphrase') || msg.includes('bad password') +} + +// โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Check if a key file exists and whether it requires a passphrase. + * Does NOT load the private key material beyond the initial probe. + */ +export async function probeSSHKey(keyPath: string): Promise { + try { + await access(keyPath, constants.R_OK) + } catch { + return {exists: false} + } + + try { + const raw = await readFile(keyPath, 'utf8') + + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + // OpenSSH format: check cipherName field + const parsed = parseOpenSSHKey(raw) + return {exists: true, needsPassphrase: parsed.cipherName !== 'none'} + } + + // PEM/PKCS8 format (RSA, ECDSA with traditional headers) + createPrivateKey({format: 'pem', key: raw}) + return {exists: true, needsPassphrase: false} + } catch (error: unknown) { + if (isPassphraseError(error)) { + return {exists: true, needsPassphrase: true} + } + + throw error + } +} + +/** + * Parse an SSH private key file into a usable signing key. + * Supports: + * - OpenSSH native format (Ed25519 only in v1; RSA/ECDSA to follow) + * - Standard PEM (PKCS#8, traditional RSA/ECDSA) + * + * Throws if passphrase is required but not provided, or if format is unsupported. + */ +export async function parseSSHPrivateKey( + keyPath: string, + passphrase?: string, +): Promise { + const raw = await readFile(keyPath, 'utf8') + + // โ”€โ”€ OpenSSH native format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + const {cipherName, keyType, privateKeyBlob} = parseOpenSSHKey(raw) + + if (cipherName !== 'none') { + if (!passphrase) { + throw new Error('Passphrase required for encrypted key') + } + + // Encrypted OpenSSH keys require decryption before parsing. + // For now, throw a clear error โ€” encrypted OpenSSH key support + // requires AES-256-CTR + bcrypt KDF implementation (out of scope for v1 spike). + throw new Error( + 'Encrypted OpenSSH private keys are not yet supported. ' + + 'Please use an unencrypted key or load it via ssh-agent.', + ) + } + + if (keyType !== 'ssh-ed25519') { + throw new Error( + `Unsupported OpenSSH key type: ${keyType}. Only ssh-ed25519 is supported in v1.`, + ) + } + + const {privateKeyPkcs8, publicKeyBlob: sshPublicKeyBlob} = + opensshEd25519ToNodeKey(privateKeyBlob) + + const privateKeyObject = createPrivateKey({ + format: 'der', + key: privateKeyPkcs8, + type: 'pkcs8', + }) + + const fingerprint = computeFingerprint(sshPublicKeyBlob) + + return { + fingerprint, + keyType, + privateKeyObject, + publicKeyBlob: sshPublicKeyBlob, + } + } + + // โ”€โ”€ Standard PEM format (PKCS8, RSA, ECDSA) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const privateKeyObject = createPrivateKey({ + format: 'pem', + key: raw, + ...(passphrase ? {passphrase} : {}), + }) + + const publicKey = createPublicKey(privateKeyObject) + + // For non-Ed25519 keys in standard PEM, derive SSH wire format manually + const asymKeyType = privateKeyObject.asymmetricKeyType + let publicKeyBlob: Buffer + let keyType: SSHKeyType + + if (asymKeyType === 'ed25519') { + const derPub = publicKey.export({format: 'der', type: 'spki'}) as Buffer + // Ed25519 SPKI DER = 12-byte ASN.1 header + 32-byte raw public key + const rawPubBytes = derPub.subarray(12) + function sshStr(data: Buffer | string): Buffer { + const b = Buffer.isBuffer(data) ? data : Buffer.from(data) + const len = Buffer.allocUnsafe(4) + len.writeUInt32BE(b.length, 0) + return Buffer.concat([len, b]) + } + + keyType = 'ssh-ed25519' + publicKeyBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(rawPubBytes)]) + } else { + throw new Error(`Unsupported key type for PEM parsing: ${asymKeyType}`) + } + + const fingerprint = computeFingerprint(publicKeyBlob) + + return {fingerprint, keyType, privateKeyObject, publicKeyBlob} +} + +/** Compute SHA256 fingerprint from SSH wire-format public key blob. */ +export function computeFingerprint(publicKeyBlob: Buffer): string { + const hash = createHash('sha256').update(publicKeyBlob).digest('base64').replace(/=+$/, '') + return `SHA256:${hash}` +} + +/** + * Attempt to extract public key metadata (fingerprint and keyType) from a key path, + * checking for a .pub file first, then attempting to parse an OpenSSH private key + * (which contains the public key even if the private key is encrypted). + */ +export async function getPublicKeyMetadata(keyPath: string): Promise { + const pubPath = keyPath.endsWith('.pub') ? keyPath : `${keyPath}.pub` + try { + const rawPub = await readFile(pubPath, 'utf8') + const parts = rawPub.trim().split(' ') + if (parts.length >= 2) { + const keyType = parts[0] + const blob = Buffer.from(parts[1], 'base64') + return {fingerprint: computeFingerprint(blob), keyType} + } + } catch { + // Ignore error, fallback to private key + } + + try { + const raw = await readFile(keyPath, 'utf8') + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + const parsed = parseOpenSSHKey(raw) + return { + fingerprint: computeFingerprint(parsed.publicKeyBlob), + keyType: parsed.keyType, + } + } + } catch { + return null + } + + return null +} + +/** + * Resolve ~ to the user's home directory in a key path. + */ +export function resolveHome(keyPath: string): string { + if (keyPath.startsWith('~/') || keyPath === '~') { + return keyPath.replace('~', homedir()) + } + + return keyPath +} diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts new file mode 100644 index 000000000..80d415310 --- /dev/null +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -0,0 +1,83 @@ +import {createHash, sign} from 'node:crypto' + +import type {ParsedSSHKey, SSHSignatureResult} from './types.js' + +// sshsig constants +// Envelope magic: 6 bytes, no null terminator (per PROTOCOL.sshsig blob format) +const SSHSIG_MAGIC = Buffer.from('SSHSIG') +// Signed data magic: 7 bytes, WITH null terminator (per PROTOCOL.sshsig ยง2 signed data) +const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG\0') +const SSHSIG_VERSION = 1 +const NAMESPACE = 'git' +const HASH_ALGORITHM = 'sha512' + +/** + * Encode a Buffer or string as an SSH wire-format length-prefixed string. + * Format: uint32(len) + bytes + */ +function sshString(data: Buffer | string): Buffer { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8') + const lenBuf = Buffer.allocUnsafe(4) + lenBuf.writeUInt32BE(buf.length, 0) + return Buffer.concat([lenBuf, buf]) +} + +/** + * Create an SSH signature for a commit payload using the sshsig format. + * + * The returned armored signature is suitable for embedding directly as + * the `gpgsig` header value in a git commit object. + * + * @param payload - The raw commit object text (as passed by isomorphic-git's onSign callback) + * @param key - Parsed SSH key from ssh-key-parser + */ +export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignatureResult { + // 1. Hash the commit payload with SHA-512 + // isomorphic-git passes payload as a string; convert to bytes first + const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() + + // 2. Build the "signed data" structure per PROTOCOL.sshsig ยง2 + // This is what the private key actually signs โ€” NOT the raw payload. + const signedData = Buffer.concat([ + SSHSIG_SIGNED_DATA_MAGIC, // "SSHSIG\0" (7 bytes with null terminator) + sshString(NAMESPACE), // "git" + sshString(''), // reserved (empty) + sshString(HASH_ALGORITHM), // "sha512" + sshString(messageHash), // H(payload) + ]) + + // 3. Sign the signed data with the private key + // Ed25519: sign(null, data, key) โ€” algorithm is implicit + // RSA: sign('sha512', data, key) โ€” must specify hash + // ECDSA: sign(null, data, key) โ€” algorithm follows curve + const rawSignature = sign(null, signedData, key.privateKeyObject) + + // 4. Build the SSH signature blob (key-type-specific wrapper) + // Ed25519: string("ssh-ed25519") + string(64-byte-sig) + // RSA: string("rsa-sha2-512") + string(rsa-sig) + // ECDSA: string("ecdsa-sha2-nistp256") + string(ecdsa-sig) + const signatureBlob = Buffer.concat([sshString(key.keyType), sshString(rawSignature)]) + + // 5. Build the full sshsig binary envelope per PROTOCOL.sshsig ยง3 + const versionBuf = Buffer.allocUnsafe(4) + versionBuf.writeUInt32BE(SSHSIG_VERSION, 0) + + const sshsigBinary = Buffer.concat([ + SSHSIG_MAGIC, // magic preamble + versionBuf, // version = 1 + sshString(key.publicKeyBlob), // public key blob + sshString(NAMESPACE), // "git" + sshString(''), // reserved + sshString(HASH_ALGORITHM), // "sha512" + sshString(signatureBlob), // wrapped signature + ]) + + // 6. Armor with PEM-style headers (76-char line wrapping, as ssh-keygen does) + const base64 = sshsigBinary.toString('base64') + const lines = base64.match(/.{1,76}/g) ?? [base64] + const armored = ['-----BEGIN SSH SIGNATURE-----', ...lines, '-----END SSH SIGNATURE-----'].join( + '\n', + ) + + return {armored, raw: sshsigBinary} +} diff --git a/src/server/infra/ssh/types.ts b/src/server/infra/ssh/types.ts new file mode 100644 index 000000000..9af2d1602 --- /dev/null +++ b/src/server/infra/ssh/types.ts @@ -0,0 +1,37 @@ +import type * as crypto from 'node:crypto' + +export type SSHKeyType = + | 'ecdsa-sha2-nistp256' + | 'ecdsa-sha2-nistp384' + | 'ecdsa-sha2-nistp521' + | 'ssh-ed25519' + | 'ssh-rsa' + +export interface ParsedSSHKey { + /** SHA256 fingerprint โ€” used for display and matching with IAM */ + fingerprint: string + /** Key type identifier (e.g., 'ssh-ed25519') */ + keyType: SSHKeyType + /** Node.js crypto KeyObject โ€” opaque, not extractable */ + privateKeyObject: crypto.KeyObject + /** Raw public key blob in SSH wire format (for embedding in sshsig) */ + publicKeyBlob: Buffer +} + +export interface SSHSignatureResult { + /** Armored SSH signature (-----BEGIN SSH SIGNATURE----- ... -----END SSH SIGNATURE-----) */ + armored: string + /** Raw sshsig binary (before base64 armoring) */ + raw: Buffer +} + +export interface SSHKeyProbeResult { + exists: false +} + +export interface SSHKeyProbeResultFound { + exists: true + needsPassphrase: boolean +} + +export type SSHKeyProbe = SSHKeyProbeResult | SSHKeyProbeResultFound diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index 5fab84b80..0faed7190 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -24,6 +24,8 @@ export {ResetHandler} from './reset-handler.js' export type {ResetHandlerDeps} from './reset-handler.js' export {ReviewHandler} from './review-handler.js' export type {ReviewHandlerDeps} from './review-handler.js' +export {SigningKeyHandler} from './signing-key-handler.js' +export type {SigningKeyHandlerDeps} from './signing-key-handler.js' export {SourceHandler} from './source-handler.js' export type {SourceHandlerDeps} from './source-handler.js' export {SpaceHandler} from './space-handler.js' diff --git a/src/server/infra/transport/handlers/signing-key-handler.ts b/src/server/infra/transport/handlers/signing-key-handler.ts new file mode 100644 index 000000000..348c9b1f6 --- /dev/null +++ b/src/server/infra/transport/handlers/signing-key-handler.ts @@ -0,0 +1,85 @@ +import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' +import type {ISigningKeyService, SigningKeyResource} from '../../../core/interfaces/services/i-signing-key-service.js' +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + type IVcSigningKeyRequest, + type IVcSigningKeyResponse, + type SigningKeyItem, + VcEvents, +} from '../../../../shared/transport/events/vc-events.js' +import {AuthenticatedHttpClient} from '../../http/authenticated-http-client.js' +import {HttpSigningKeyService} from '../../iam/http-signing-key-service.js' + +export interface SigningKeyHandlerDeps { + iamBaseUrl: string + tokenStore: ITokenStore + transport: ITransportServer +} + +function toSigningKeyItem(resource: SigningKeyResource): SigningKeyItem { + return { + createdAt: resource.createdAt, + fingerprint: resource.fingerprint, + id: resource.id, + keyType: resource.keyType, + lastUsedAt: resource.lastUsedAt, + publicKey: resource.publicKey, + title: resource.title, + } +} + +/** + * Handles vc:signing-key events from the CLI. + * Creates a fresh authenticated HTTP client per request to use the current session key. + */ +export class SigningKeyHandler { + private readonly iamBaseUrl: string + private readonly tokenStore: ITokenStore + private readonly transport: ITransportServer + + constructor(deps: SigningKeyHandlerDeps) { + this.iamBaseUrl = deps.iamBaseUrl + this.tokenStore = deps.tokenStore + this.transport = deps.transport + } + + setup(): void { + this.transport.onRequest( + VcEvents.SIGNING_KEY, + async (data) => { + const signingKeyService = await this.createService() + return this.handle(signingKeyService, data) + }, + ) + } + + private async createService(): Promise { + const token = await this.tokenStore.load() + const sessionKey = token?.sessionKey ?? '' + const httpClient = new AuthenticatedHttpClient(sessionKey) + return new HttpSigningKeyService(httpClient, this.iamBaseUrl) + } + + private async handle( + service: ISigningKeyService, + data: IVcSigningKeyRequest, + ): Promise { + switch (data.action) { + case 'add': { + const key = await service.addKey(data.title, data.publicKey) + return {action: 'add', key: toSigningKeyItem(key)} + } + + case 'list': { + const keys = await service.listKeys() + return {action: 'list', keys: keys.map((k) => toSigningKeyItem(k))} + } + + case 'remove': { + await service.removeKey(data.keyId) + return {action: 'remove'} + } + } + } +} diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 924b2dc58..cce95887a 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -1,5 +1,7 @@ +import {execFile} from 'node:child_process' import fs from 'node:fs' import {join} from 'node:path' +import {promisify} from 'node:util' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' import type {IContextTreeService} from '../../../core/interfaces/context-tree/i-context-tree-service.js' @@ -52,6 +54,15 @@ import {NotAuthenticatedError} from '../../../core/domain/errors/task-error.js' import {VcError} from '../../../core/domain/errors/vc-error.js' import {ensureContextTreeGitignore, ensureGitignoreEntries} from '../../../utils/gitignore.js' import {buildCogitRemoteUrl, isValidBranchName, parseUserFacingUrl} from '../../git/cogit-url.js' +import { + type ParsedSSHKey, + parseSSHPrivateKey, + probeSSHKey, + resolveHome, + signCommitPayload, + SigningKeyCache, + tryGetSshAgentSigner, +} from '../../ssh/index.js' import {type ProjectBroadcaster, type ProjectPathResolver, resolveRequiredProjectPath} from './handler-types.js' /** @@ -76,9 +87,12 @@ function classifyIsomorphicGitError(error: unknown, notFoundCode: VcErrorCodeTyp return undefined } -const FIELD_MAP: Record = { +// FIELD_MAP maps vc config keys to IVcGitConfig field names +const FIELD_MAP: Record = { + 'commit.sign': 'commitSign', 'user.email': 'email', 'user.name': 'name', + 'user.signingkey': 'signingKey', } export interface IVcHandlerDeps { @@ -88,6 +102,7 @@ export interface IVcHandlerDeps { gitService: IGitService projectConfigStore: IProjectConfigStore resolveProjectPath: ProjectPathResolver + signingKeyCache?: SigningKeyCache spaceService: ISpaceService teamService: ITeamService tokenStore: ITokenStore @@ -106,6 +121,7 @@ export class VcHandler { private readonly gitService: IGitService private readonly projectConfigStore: IProjectConfigStore private readonly resolveProjectPath: ProjectPathResolver + private readonly signingKeyCache: SigningKeyCache private readonly spaceService: ISpaceService private readonly teamService: ITeamService private readonly tokenStore: ITokenStore @@ -120,6 +136,7 @@ export class VcHandler { this.gitService = deps.gitService this.projectConfigStore = deps.projectConfigStore this.resolveProjectPath = deps.resolveProjectPath + this.signingKeyCache = deps.signingKeyCache ?? new SigningKeyCache() this.spaceService = deps.spaceService this.teamService = deps.teamService this.tokenStore = deps.tokenStore @@ -173,21 +190,6 @@ export class VcHandler { this.transport.onRequest(VcEvents.STATUS, (_data, clientId) => this.handleStatus(clientId)) } - private async buildAuthorHint(existing?: IVcGitConfig): Promise { - try { - const token = await this.tokenStore.load() - if (token?.isValid()) { - const email = existing?.email ?? token.userEmail - const name = existing?.name ?? token.userName ?? token.userEmail - return `Run: brv vc config user.name '${name}' and brv vc config user.email '${email}'.` - } - } catch { - // not logged in - } - - return 'Run: brv vc config user.name and brv vc config user.email .' - } - private buildNoRemoteMessage(nextStep: string): string { return ( `No remote configured.\n\nTo connect to cloud:\n` + @@ -541,15 +543,50 @@ export class VcHandler { } const config = await this.vcGitConfigStore.get(projectPath) - if (!config?.name || !config.email) { - const hint = await this.buildAuthorHint(config) - throw new VcError(`Commit author not configured. ${hint}`, VcErrorCode.USER_NOT_CONFIGURED) + const author = await this.resolveAuthor(config) + + // โ”€โ”€ Option C SSH Signing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // + // Determine whether to sign this commit: + // data.sign=true โ†’ force sign + // data.sign=false โ†’ force no-sign + // undefined โ†’ use config.commitSign, or auto-sign if signingKey is configured + const shouldSign = data.sign ?? config?.commitSign ?? config?.signingKey !== undefined + + let onSign: ((payload: string) => Promise) | undefined + + if (shouldSign) { + const keyPath = config?.signingKey ? resolveHome(config.signingKey) : undefined + + if (!keyPath) { + throw new VcError( + 'Signing key not configured. Run: brv vc config user.signingkey ~/.ssh/id_ed25519', + VcErrorCode.SIGNING_KEY_NOT_CONFIGURED, + ) + } + + // Path A: try SSH agent first (zero-prompt, works even for encrypted keys) + const agentSigner = await tryGetSshAgentSigner(keyPath) + if (agentSigner) { + onSign = async (payload: string) => { + const result = await agentSigner.sign(payload) + return result.armored + } + } else { + // Path B/C: cache or file-based parsing + const parsed = await this.resolveSigningKey(keyPath, data.passphrase) + onSign = async (payload: string) => { + const result = signCommitPayload(payload, parsed) + return result.armored + } + } } const commit = await this.gitService.commit({ - author: {email: config.email, name: config.name}, + author, directory, message: data.message, + ...(onSign ? {onSign} : {}), }) return {message: commit.message, sha: commit.sha} @@ -558,14 +595,70 @@ export class VcHandler { private async handleConfig(data: IVcConfigRequest, clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) + // โ”€โ”€ --import-git-signing branch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Reads `user.signingKey` and `commit.gpgSign` from local/global git config + // and writes them into the brv VcGitConfigStore. + if (data.importGitSigning) { + return this.handleImportGitSigning(projectPath) + } + const field = FIELD_MAP[data.key] if (!field) { - throw new VcError(`Unknown key '${data.key}'. Allowed: user.name, user.email.`, VcErrorCode.INVALID_CONFIG_KEY) + throw new VcError( + `Unknown key '${data.key}'. Allowed: user.name, user.email, user.signingkey, commit.sign.`, + VcErrorCode.INVALID_CONFIG_KEY, + ) } if (data.value !== undefined) { - // SET: read existing โ†’ merge single field โ†’ write back + // SET: read existing โ†’ coerce value โ†’ merge โ†’ write back const existing = (await this.vcGitConfigStore.get(projectPath)) ?? {} + + // Coerce 'commit.sign' value to boolean + if (field === 'commitSign') { + const boolValue = data.value === 'true' || data.value === '1' + if (data.value !== 'true' && data.value !== 'false' && data.value !== '1' && data.value !== '0') { + throw new VcError( + `Invalid value for commit.sign: '${data.value}'. Use true, false, 1, or 0.`, + VcErrorCode.INVALID_CONFIG_VALUE, + ) + } + + await this.vcGitConfigStore.set(projectPath, {...existing, commitSign: boolValue}) + return {key: data.key, value: String(boolValue)} + } + + // Coerce 'user.signingkey': resolve ~ to home and validate file exists + if (field === 'signingKey') { + let resolvedPath = resolveHome(data.value) + if (resolvedPath.endsWith('.pub')) { + resolvedPath = resolvedPath.slice(0, -4) + } + + const probe = await probeSSHKey(resolvedPath) + if (!probe.exists) { + throw new VcError( + `SSH key not found at: ${resolvedPath}`, + VcErrorCode.SIGNING_KEY_NOT_FOUND, + ) + } + + // Derive fingerprint for display hint (non-blocking if parse fails) + let hint: string | undefined + try { + const parsed = await parseSSHPrivateKey(resolvedPath) + // Cache the parsed key for immediate use + this.signingKeyCache.set(resolvedPath, parsed) + hint = `Fingerprint: ${parsed.fingerprint}` + } catch { + // Encrypted key โ€” require passphrase to get fingerprint; skip hint + } + + await this.vcGitConfigStore.set(projectPath, {...existing, signingKey: resolvedPath}) + return {hint, key: data.key, value: resolvedPath} + } + + // Regular string fields (user.name, user.email) const merged = {...existing, [field]: data.value} await this.vcGitConfigStore.set(projectPath, merged) return {key: data.key, value: data.value} @@ -573,12 +666,12 @@ export class VcHandler { // GET const config = await this.vcGitConfigStore.get(projectPath) - const value = config?.[field] - if (value === undefined) { + const rawValue = config?.[field] + if (rawValue === undefined) { throw new VcError(`'${data.key}' is not set.`, VcErrorCode.CONFIG_KEY_NOT_SET) } - return {key: data.key, value} + return {key: data.key, value: String(rawValue)} } private async handleFetch(data: IVcFetchRequest, clientId: string): Promise { @@ -616,6 +709,74 @@ export class VcHandler { return {remote} } + /** + * Import SSH signing config from local/global git config. + * Reads these git keys (falls back local โ†’ global): + * user.signingKey โ†’ brv user.signingkey + * commit.gpgSign โ†’ brv commit.sign + * gpg.format โ†’ must be 'ssh' (warns otherwise) + */ + private async handleImportGitSigning(projectPath: string): Promise { + const execFileAsync = promisify(execFile) + + async function readGitConfig(key: string): Promise { + try { + const {stdout} = await execFileAsync('git', ['config', '--get', key], {cwd: projectPath}) + return stdout.trim() || undefined + } catch { + return undefined + } + } + + const signingKey = await readGitConfig('user.signingKey') + const gpgFormat = await readGitConfig('gpg.format') + const gpgSign = await readGitConfig('commit.gpgSign') + + if (!signingKey) { + throw new VcError( + 'No user.signingKey found in git config. Configure it with: git config user.signingKey ~/.ssh/id_ed25519', + VcErrorCode.SIGNING_KEY_NOT_FOUND, + ) + } + + if (gpgFormat && gpgFormat !== 'ssh') { + throw new VcError( + `git config gpg.format is '${gpgFormat}', not 'ssh'. brv only supports SSH commit signing.`, + VcErrorCode.INVALID_CONFIG_VALUE, + ) + } + + let resolvedPath = resolveHome(signingKey) + if (resolvedPath.endsWith('.pub')) { + resolvedPath = resolvedPath.slice(0, -4) + } + + const probe = await probeSSHKey(resolvedPath) + if (!probe.exists) { + throw new VcError( + `SSH key from git config not found at: ${resolvedPath}`, + VcErrorCode.SIGNING_KEY_NOT_FOUND, + ) + } + + // Read and merge into brv config + const existing = (await this.vcGitConfigStore.get(projectPath)) ?? {} + const commitSign = gpgSign === 'true' || gpgSign === '1' + const updated: typeof existing = {...existing, commitSign, signingKey: resolvedPath} + await this.vcGitConfigStore.set(projectPath, updated) + + let hint: string | undefined + try { + const parsed = await parseSSHPrivateKey(resolvedPath) + this.signingKeyCache.set(resolvedPath, parsed) + hint = `Fingerprint: ${parsed.fingerprint} | commit.sign: ${String(commitSign)}` + } catch { + hint = `commit.sign: ${String(commitSign)}` + } + + return {hint, key: 'user.signingkey', value: resolvedPath} + } + private async handleInit(clientId: string): Promise { const projectPath = resolveRequiredProjectPath(this.resolveProjectPath, clientId) @@ -715,14 +876,11 @@ export class VcHandler { throw new VcError('Committing is not possible because you have unmerged files.', VcErrorCode.MERGE_CONFLICT) } - const config = await this.vcGitConfigStore.get(projectPath) - if (!config?.name || !config.email) { - const hint = await this.buildAuthorHint(config) - throw new VcError(`Commit author not configured. ${hint}`, VcErrorCode.USER_NOT_CONFIGURED) - } + const mergeConfig = await this.vcGitConfigStore.get(projectPath) + const mergeAuthor = await this.resolveAuthor(mergeConfig) await this.gitService.commit({ - author: {email: config.email, name: config.name}, + author: mergeAuthor, directory, message: data.message, }) @@ -757,14 +915,11 @@ export class VcHandler { } const config = await this.vcGitConfigStore.get(projectPath) - if (!config?.name || !config.email) { - const hint = await this.buildAuthorHint(config) - throw new VcError(`Commit author not configured. ${hint}`, VcErrorCode.USER_NOT_CONFIGURED) - } + const mergeStartAuthor = await this.resolveAuthor(config) const result = await this.gitService.merge({ allowUnrelatedHistories: data.allowUnrelatedHistories, - author: {email: config.email, name: config.name}, + author: mergeStartAuthor, branch: data.branch, directory, message: data.message, @@ -802,10 +957,14 @@ export class VcHandler { throw new VcError(this.buildNoRemoteMessage('brv vc pull origin main'), VcErrorCode.NO_REMOTE) } - // Soft resolve author: use vc config if available, otherwise let pull() fallback to getAuthor() from auth token. - // Unlike commit/merge, pull only needs author when creating a merge commit (not for up-to-date or fast-forward). + // Resolve author for merge commits during pull (fallback to auth token if config not set) const config = await this.vcGitConfigStore.get(projectPath) - const author = config?.name && config?.email ? {email: config.email, name: config.name} : undefined + let author: undefined | {email: string; name: string} + try { + author = await this.resolveAuthor(config) + } catch { + // Fallback: proceed without author if resolution fails + } // If explicit branch provided, use it directly (skip tracking resolution) const remote = data?.remote ?? 'origin' @@ -1144,6 +1303,32 @@ export class VcHandler { } } + /** + * Resolve commit author: config values first, then fallback to auth token. + * Throws USER_NOT_CONFIGURED only when neither source has name+email. + */ + private async resolveAuthor(config?: IVcGitConfig): Promise<{email: string; name: string}> { + if (config?.name && config.email) { + return {email: config.email, name: config.name} + } + + try { + const token = await this.tokenStore.load() + if (token?.isValid()) { + const email = config?.email ?? token.userEmail + const name = config?.name ?? token.userName ?? token.userEmail + if (email && name) return {email, name} + } + } catch { + // not logged in + } + + throw new VcError( + 'Commit author not configured. Run: brv vc config user.name and brv vc config user.email .', + VcErrorCode.USER_NOT_CONFIGURED, + ) + } + /** * Resolve clone request data into a clean cogit URL + team/space info. * Accepts either a URL or explicit teamName/spaceName. @@ -1277,6 +1462,40 @@ export class VcHandler { throw new VcError('Cannot determine branch for pull. Check out a branch first.', VcErrorCode.NO_BRANCH_RESOLVED) } + /** + * File-based signing key resolution (paths B and C only). + * Path A (ssh-agent) is handled in handleCommit before calling this method. + * + * Path B: in-memory TTL cache (passphrase not needed after first use) + * Path C: parse key file (may require passphrase โ€” delegates to CLI caller via PASSPHRASE_REQUIRED error) + */ + private async resolveSigningKey(keyPath: string, passphrase?: string): Promise { + // Path B: in-memory TTL cache + const cachedKey = this.signingKeyCache.get(keyPath) + if (cachedKey) return cachedKey + + // Path C: parse key file + const probe = await probeSSHKey(keyPath) + if (!probe.exists) { + throw new VcError( + `SSH key file not found: ${keyPath}`, + VcErrorCode.SIGNING_KEY_NOT_FOUND, + ) + } + + if (probe.needsPassphrase && !passphrase) { + throw new VcError( + `SSH key at ${keyPath} requires a passphrase. Retry with your passphrase.`, + VcErrorCode.PASSPHRASE_REQUIRED, + ) + } + + const parsed = await parseSSHPrivateKey(keyPath, passphrase) + // Store in cache (passphrase is not stored โ€” only the decrypted KeyObject) + this.signingKeyCache.set(keyPath, parsed) + return parsed + } + private async resolveTargetBranch(requestedBranch: string | undefined, directory: string): Promise { const trimmed = requestedBranch?.trim() diff --git a/src/server/infra/vc/file-vc-git-config-store.ts b/src/server/infra/vc/file-vc-git-config-store.ts index 844ea17a4..e4669ea59 100644 --- a/src/server/infra/vc/file-vc-git-config-store.ts +++ b/src/server/infra/vc/file-vc-git-config-store.ts @@ -21,7 +21,12 @@ function projectKey(projectPath: string): string { function isIVcGitConfig(value: unknown): value is IVcGitConfig { if (typeof value !== 'object' || value === null) return false const v = value as Record - return (v.name === undefined || typeof v.name === 'string') && (v.email === undefined || typeof v.email === 'string') + return ( + (v.name === undefined || typeof v.name === 'string') && + (v.email === undefined || typeof v.email === 'string') && + (v.signingKey === undefined || typeof v.signingKey === 'string') && + (v.commitSign === undefined || typeof v.commitSign === 'boolean') + ) } export class FileVcGitConfigStore implements IVcGitConfigStore { diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index e4f3fa3b7..15c669cad 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -14,6 +14,7 @@ export const VcErrorCode = { INVALID_ACTION: 'ERR_VC_INVALID_ACTION', INVALID_BRANCH_NAME: 'ERR_VC_INVALID_BRANCH_NAME', INVALID_CONFIG_KEY: 'ERR_VC_INVALID_CONFIG_KEY', + INVALID_CONFIG_VALUE: 'ERR_VC_INVALID_CONFIG_VALUE', INVALID_REF: 'ERR_VC_INVALID_REF', INVALID_REMOTE_URL: 'ERR_VC_INVALID_REMOTE_URL', MERGE_CONFLICT: 'ERR_VC_MERGE_CONFLICT', @@ -28,9 +29,12 @@ export const VcErrorCode = { NOTHING_STAGED: 'ERR_VC_NOTHING_STAGED', NOTHING_TO_PUSH: 'ERR_VC_NOTHING_TO_PUSH', NOTHING_TO_RESET: 'ERR_VC_NOTHING_TO_RESET', + PASSPHRASE_REQUIRED: 'ERR_VC_PASSPHRASE_REQUIRED', PULL_FAILED: 'ERR_VC_PULL_FAILED', PUSH_FAILED: 'ERR_VC_PUSH_FAILED', REMOTE_ALREADY_EXISTS: 'ERR_VC_REMOTE_ALREADY_EXISTS', + SIGNING_KEY_NOT_CONFIGURED: 'ERR_VC_SIGNING_KEY_NOT_CONFIGURED', + SIGNING_KEY_NOT_FOUND: 'ERR_VC_SIGNING_KEY_NOT_FOUND', UNCOMMITTED_CHANGES: 'ERR_VC_UNCOMMITTED_CHANGES', UNRELATED_HISTORIES: 'ERR_VC_UNRELATED_HISTORIES', USER_NOT_CONFIGURED: 'ERR_VC_USER_NOT_CONFIGURED', @@ -54,6 +58,7 @@ export const VcEvents = { PUSH: 'vc:push', REMOTE: 'vc:remote', RESET: 'vc:reset', + SIGNING_KEY: 'vc:signing-key', STATUS: 'vc:status', } as const @@ -87,6 +92,10 @@ export interface IVcAddResponse { export interface IVcCommitRequest { message: string + /** Passphrase for encrypted SSH key โ€” only sent on retry after PASSPHRASE_REQUIRED error */ + passphrase?: string + /** Override signing config: true = force sign, false = force no-sign, undefined = use config */ + sign?: boolean } export interface IVcCommitResponse { @@ -94,24 +103,59 @@ export interface IVcCommitResponse { sha: string } -export type VcConfigKey = 'user.email' | 'user.name' +export type VcConfigKey = 'commit.sign' | 'user.email' | 'user.name' | 'user.signingkey' -export const VC_CONFIG_KEYS: readonly string[] = ['user.name', 'user.email'] satisfies readonly VcConfigKey[] +export const VC_CONFIG_KEYS: readonly string[] = [ + 'user.name', + 'user.email', + 'user.signingkey', + 'commit.sign', +] satisfies readonly VcConfigKey[] export function isVcConfigKey(key: string): key is VcConfigKey { return VC_CONFIG_KEYS.includes(key) } export interface IVcConfigRequest { + /** If true, import SSH signing config from local/global git config */ + importGitSigning?: boolean + /** Config key (e.g., 'user.email', 'user.signingkey') */ key: VcConfigKey + /** Value to set. Omit for GET. For 'commit.sign': 'true' or 'false'. */ value?: string } export interface IVcConfigResponse { + /** Optional display hint (e.g., fingerprint after setting signingkey) */ + hint?: string key: string value: string } +// โ”€โ”€ Signing Key Management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type VcSigningKeyAction = 'add' | 'list' | 'remove' + +export type IVcSigningKeyRequest = + | {action: 'add'; publicKey: string; title: string} + | {action: 'list'} + | {action: 'remove'; keyId: string} + +export interface SigningKeyItem { + createdAt: string + fingerprint: string + id: string + keyType: string + lastUsedAt?: string + publicKey: string + title: string +} + +export type IVcSigningKeyResponse = + | {action: 'add'; key: SigningKeyItem} + | {action: 'list'; keys: SigningKeyItem[]} + | {action: 'remove'} + export interface IVcPushRequest { branch?: string setUpstream?: boolean diff --git a/test/unit/infra/ssh/signing-key-cache.test.ts b/test/unit/infra/ssh/signing-key-cache.test.ts new file mode 100644 index 000000000..d5fc087e0 --- /dev/null +++ b/test/unit/infra/ssh/signing-key-cache.test.ts @@ -0,0 +1,150 @@ +import {expect} from 'chai' + +import type {ParsedSSHKey} from '../../../../src/server/infra/ssh/types.js' + +import {SigningKeyCache} from '../../../../src/server/infra/ssh/signing-key-cache.js' + +// Minimal stub ParsedSSHKey โ€” only needs the shape for caching tests +function makeFakeKey(id: string): ParsedSSHKey { + return { + fingerprint: `SHA256:${id}`, + keyType: 'ssh-ed25519', + privateKeyObject: {} as never, + publicKeyBlob: Buffer.from(id), + } +} + +describe('SigningKeyCache', () => { + describe('get() / set()', () => { + it('returns null for unknown key path', () => { + const cache = new SigningKeyCache() + expect(cache.get('/nonexistent/key')).to.be.null + }) + + it('returns stored key immediately after set()', () => { + const cache = new SigningKeyCache() + const key = makeFakeKey('abc') + cache.set('/home/user/.ssh/id_ed25519', key) + expect(cache.get('/home/user/.ssh/id_ed25519')).to.equal(key) + }) + + it('different paths are stored independently', () => { + const cache = new SigningKeyCache() + const key1 = makeFakeKey('k1') + const key2 = makeFakeKey('k2') + cache.set('/path/to/key1', key1) + cache.set('/path/to/key2', key2) + expect(cache.get('/path/to/key1')).to.equal(key1) + expect(cache.get('/path/to/key2')).to.equal(key2) + }) + + it('overwriting a key replaces it', () => { + const cache = new SigningKeyCache() + const key1 = makeFakeKey('v1') + const key2 = makeFakeKey('v2') + cache.set('/same/path', key1) + cache.set('/same/path', key2) + expect(cache.get('/same/path')).to.equal(key2) + }) + }) + + describe('size', () => { + it('is 0 for empty cache', () => { + const cache = new SigningKeyCache() + expect(cache.size).to.equal(0) + }) + + it('counts non-expired entries', () => { + const cache = new SigningKeyCache() + cache.set('/a', makeFakeKey('a')) + cache.set('/b', makeFakeKey('b')) + expect(cache.size).to.equal(2) + }) + }) + + describe('TTL expiry', () => { + it('returns null after TTL expires', async function () { + this.timeout(3000) + + // Create cache with 50ms TTL for fast test + const cache = new SigningKeyCache(50) + const key = makeFakeKey('ttl-test') + cache.set('/ttl/test', key) + + // Still accessible immediately + expect(cache.get('/ttl/test')).to.equal(key) + + // Wait for TTL to expire + await new Promise((resolve) => { setTimeout(resolve, 100) }) + + expect(cache.get('/ttl/test')).to.be.null + }) + + it('returns key before TTL expires', async function () { + this.timeout(3000) + + const cache = new SigningKeyCache(500) + const key = makeFakeKey('ttl-alive') + cache.set('/ttl/alive', key) + + await new Promise((resolve) => { setTimeout(resolve, 50) }) + + expect(cache.get('/ttl/alive')).to.equal(key) + }) + }) + + describe('expired entry cleanup on set()', () => { + it('removes expired entries when a new key is set', async function () { + this.timeout(3000) + + const cache = new SigningKeyCache(50) + cache.set('/old', makeFakeKey('old')) + + // Wait for TTL to expire + await new Promise((resolve) => { setTimeout(resolve, 100) }) + + // Set a new key โ€” should sweep the expired '/old' entry + cache.set('/new', makeFakeKey('new')) + + // The expired entry should have been cleaned up from internal storage + // size only counts non-expired, so it should be 1 + expect(cache.size).to.equal(1) + expect(cache.get('/old')).to.be.null + expect(cache.get('/new')).to.not.be.null + }) + }) + + describe('invalidate()', () => { + it('removes a specific key path from cache', () => { + const cache = new SigningKeyCache() + const key = makeFakeKey('to-clear') + cache.set('/invalidate/me', key) + cache.invalidate('/invalidate/me') + expect(cache.get('/invalidate/me')).to.be.null + }) + + it('does not affect other cached keys when invalidating one', () => { + const cache = new SigningKeyCache() + const key1 = makeFakeKey('keep') + const key2 = makeFakeKey('remove') + cache.set('/keep', key1) + cache.set('/remove', key2) + cache.invalidate('/remove') + expect(cache.get('/keep')).to.equal(key1) + expect(cache.get('/remove')).to.be.null + }) + }) + + describe('invalidateAll()', () => { + it('clears all entries', () => { + const cache = new SigningKeyCache() + cache.set('/a', makeFakeKey('a')) + cache.set('/b', makeFakeKey('b')) + cache.set('/c', makeFakeKey('c')) + cache.invalidateAll() + expect(cache.size).to.equal(0) + expect(cache.get('/a')).to.be.null + expect(cache.get('/b')).to.be.null + }) + }) +}) diff --git a/test/unit/infra/ssh/ssh-agent-signer.test.ts b/test/unit/infra/ssh/ssh-agent-signer.test.ts new file mode 100644 index 000000000..0f6bc1b63 --- /dev/null +++ b/test/unit/infra/ssh/ssh-agent-signer.test.ts @@ -0,0 +1,372 @@ +import {expect} from 'chai' +import {mkdtempSync, writeFileSync} from 'node:fs' +import net from 'node:net' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {tryGetSshAgentSigner} from '../../../../src/server/infra/ssh/ssh-agent-signer.js' +import {parseSSHPrivateKey} from '../../../../src/server/infra/ssh/ssh-key-parser.js' + +// โ”€โ”€ Test key โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Same Ed25519 test key used across SSH tests (NOT a production key). +const TEST_OPENSSH_ED25519_KEY = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + +// โ”€โ”€ SSH agent protocol constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const SSH_AGENTC_REQUEST_IDENTITIES = 11 +const SSH_AGENTC_SIGN_REQUEST = 13 +const SSH_AGENT_IDENTITIES_ANSWER = 12 +const SSH_AGENT_SIGN_RESPONSE = 14 + +function writeUInt32BE(value: number): Buffer { + const buf = Buffer.allocUnsafe(4) + buf.writeUInt32BE(value, 0) + return buf +} + +function sshString(data: Buffer | string): Buffer { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8') + return Buffer.concat([writeUInt32BE(buf.length), buf]) +} + +// โ”€โ”€ Mock SSH agent server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Creates a minimal mock SSH agent that responds to IDENTITIES and SIGN requests. + * Returns the socket path and a cleanup function. + */ +function createMockAgent( + identities: Array<{blob: Buffer; comment: string}>, + signResponse: Buffer, +): {cleanup: () => void; socketPath: string} { + const tempDir = mkdtempSync(join(tmpdir(), 'brv-mock-agent-')) + const socketPath = join(tempDir, 'agent.sock') + + const server = net.createServer((conn) => { + const chunks: Buffer[] = [] + + conn.on('data', (chunk) => { + chunks.push(chunk) + const accumulated = Buffer.concat(chunks) + if (accumulated.length < 4) return + const msgLen = accumulated.readUInt32BE(0) + if (accumulated.length < 4 + msgLen) return + + // Consume the message + const payload = accumulated.slice(4, 4 + msgLen) + chunks.length = 0 + if (accumulated.length > 4 + msgLen) { + chunks.push(accumulated.slice(4 + msgLen)) + } + + const msgType = payload[0] + + if (msgType === SSH_AGENTC_REQUEST_IDENTITIES) { + // Build identities response + const parts: Buffer[] = [ + Buffer.from([SSH_AGENT_IDENTITIES_ANSWER]), + writeUInt32BE(identities.length), + ] + for (const id of identities) { + parts.push(sshString(id.blob), sshString(id.comment)) + } + + const body = Buffer.concat(parts) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } else if (msgType === SSH_AGENTC_SIGN_REQUEST) { + // Return the pre-configured sign response + const body = Buffer.concat([ + Buffer.from([SSH_AGENT_SIGN_RESPONSE]), + sshString(signResponse), + ]) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } + }) + }) + + server.listen(socketPath) + + return { + cleanup() { + server.close() + }, + socketPath, + } +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('ssh-agent-signer', () => { +describe('tryGetSshAgentSigner()', () => { + let tempDir: string + let keyPath: string + let originalAuthSock: string | undefined + + before(() => { + tempDir = mkdtempSync(join(tmpdir(), 'brv-agent-test-')) + keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) + originalAuthSock = process.env.SSH_AUTH_SOCK + }) + + afterEach(() => { + if (originalAuthSock === undefined) { + delete process.env.SSH_AUTH_SOCK + } else { + process.env.SSH_AUTH_SOCK = originalAuthSock + } + }) + + it('returns null when SSH_AUTH_SOCK is not set', async () => { + delete process.env.SSH_AUTH_SOCK + const result = await tryGetSshAgentSigner(keyPath) + expect(result).to.be.null + }) + + it('returns null when agent is unreachable', async () => { + process.env.SSH_AUTH_SOCK = '/nonexistent/agent.sock' + const result = await tryGetSshAgentSigner(keyPath) + expect(result).to.be.null + }) + + it('returns null when agent has no matching key', async () => { + // Agent with a different key blob + const unrelatedBlob = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(32, 0xff)), // fake pubkey + ]) + const agent = createMockAgent( + [{blob: unrelatedBlob, comment: 'unrelated'}], + Buffer.alloc(0), + ) + process.env.SSH_AUTH_SOCK = agent.socketPath + + try { + const result = await tryGetSshAgentSigner(keyPath) + expect(result).to.be.null + } finally { + agent.cleanup() + } + }) + + it('returns a signer when agent has the matching key', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + + const fakeSignature = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(64, 0xab)), // fake sig bytes + ]) + const agent = createMockAgent( + [{blob: parsed.publicKeyBlob, comment: 'test@example.com'}], + fakeSignature, + ) + process.env.SSH_AUTH_SOCK = agent.socketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + expect(signer).to.not.be.null + } finally { + agent.cleanup() + } + }) +}) + +describe('SshAgentSigner.sign()', () => { + let tempDir: string + let keyPath: string + let originalAuthSock: string | undefined + + before(() => { + tempDir = mkdtempSync(join(tmpdir(), 'brv-agentsign-test-')) + keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) + originalAuthSock = process.env.SSH_AUTH_SOCK + }) + + afterEach(() => { + if (originalAuthSock === undefined) { + delete process.env.SSH_AUTH_SOCK + } else { + process.env.SSH_AUTH_SOCK = originalAuthSock + } + }) + + it('produces armored output with correct SSH SIGNATURE headers', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + const fakeSignature = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(64, 0xab)), + ]) + const agent = createMockAgent( + [{blob: parsed.publicKeyBlob, comment: 'test'}], + fakeSignature, + ) + process.env.SSH_AUTH_SOCK = agent.socketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + expect(signer).to.not.be.null + + const result = await signer!.sign('test commit payload') + expect(result.armored).to.match(/^-----BEGIN SSH SIGNATURE-----/) + expect(result.armored.trim()).to.match(/-----END SSH SIGNATURE-----$/) + } finally { + agent.cleanup() + } + }) + + it('raw envelope starts with 6-byte SSHSIG magic (no null)', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + const fakeSignature = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(64, 0xab)), + ]) + const agent = createMockAgent( + [{blob: parsed.publicKeyBlob, comment: 'test'}], + fakeSignature, + ) + process.env.SSH_AUTH_SOCK = agent.socketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + const result = await signer!.sign('test payload') + + // Envelope magic: 6 bytes 'SSHSIG' (no null) + const magic = result.raw.subarray(0, 6).toString() + expect(magic).to.equal('SSHSIG') + // 7th byte should be version (uint32BE = 0x00), NOT a null terminator + // Version is uint32BE(1) = [0x00, 0x00, 0x00, 0x01] + expect(result.raw.readUInt32BE(6)).to.equal(1) + } finally { + agent.cleanup() + } + }) + + it('signed data sent to agent uses 7-byte magic WITH null terminator', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + + // We'll capture the signed data sent to the agent's sign method + let capturedSignData: Buffer | undefined + const captureSocketPath = join(tempDir, 'capture-agent.sock') + const server = net.createServer((conn) => { + const chunks: Buffer[] = [] + conn.on('data', (chunk) => { + chunks.push(chunk) + const accumulated = Buffer.concat(chunks) + if (accumulated.length < 4) return + const msgLen = accumulated.readUInt32BE(0) + if (accumulated.length < 4 + msgLen) return + + const payload = accumulated.slice(4, 4 + msgLen) + chunks.length = 0 + + const msgType = payload[0] + + if (msgType === SSH_AGENTC_REQUEST_IDENTITIES) { + const body = Buffer.concat([ + Buffer.from([SSH_AGENT_IDENTITIES_ANSWER]), + writeUInt32BE(1), + sshString(parsed.publicKeyBlob), + sshString('test'), + ]) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } else if (msgType === SSH_AGENTC_SIGN_REQUEST) { + // Parse the sign request to capture the data blob + let offset = 1 + const blobLen = payload.readUInt32BE(offset) + offset += 4 + blobLen + const dataLen = payload.readUInt32BE(offset) + offset += 4 + capturedSignData = payload.slice(offset, offset + dataLen) + + // Return a fake signature + const fakeSignature = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(64, 0xab)), + ]) + const body = Buffer.concat([ + Buffer.from([SSH_AGENT_SIGN_RESPONSE]), + sshString(fakeSignature), + ]) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } + }) + }) + server.listen(captureSocketPath) + process.env.SSH_AUTH_SOCK = captureSocketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + expect(signer).to.not.be.null + await signer!.sign('test payload for magic check') + + // Verify the signed data starts with SSHSIG\0 (7 bytes) + expect(capturedSignData).to.not.be.undefined + const signedMagic = capturedSignData!.subarray(0, 7).toString('binary') + expect(signedMagic).to.equal('SSHSIG\0') + } finally { + server.close() + } + }) + + it('different payloads produce different signatures', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + const fakeSignature = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(64, 0xab)), + ]) + const agent = createMockAgent( + [{blob: parsed.publicKeyBlob, comment: 'test'}], + fakeSignature, + ) + process.env.SSH_AUTH_SOCK = agent.socketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + // Note: with a static mock signature, the armored outputs will differ + // because the signed data (and thus the data sent to agent) differs, + // but the agent returns the same fake sig. The raw envelope is identical + // except for the data we pass to agent. Since we capture the full + // envelope, the sshsig binary will be the same. + // This test verifies the function runs successfully for different payloads. + const r1 = await signer!.sign('payload one') + const r2 = await signer!.sign('payload two') + expect(r1.armored).to.be.a('string') + expect(r2.armored).to.be.a('string') + } finally { + agent.cleanup() + } + }) + + it('armored body contains valid base64', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + const fakeSignature = Buffer.concat([ + sshString('ssh-ed25519'), + sshString(Buffer.alloc(64, 0xab)), + ]) + const agent = createMockAgent( + [{blob: parsed.publicKeyBlob, comment: 'test'}], + fakeSignature, + ) + process.env.SSH_AUTH_SOCK = agent.socketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + const result = await signer!.sign('test') + const lines = result.armored.split('\n') + const bodyLines = lines.filter( + (l) => !l.startsWith('-----') && l.trim().length > 0, + ) + const b64 = bodyLines.join('') + expect(() => Buffer.from(b64, 'base64')).to.not.throw() + } finally { + agent.cleanup() + } + }) +}) +}) diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts new file mode 100644 index 000000000..8c94b9afb --- /dev/null +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -0,0 +1,169 @@ +import {expect} from 'chai' +import {mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {parseSSHPrivateKey, probeSSHKey, resolveHome} from '../../../../src/server/infra/ssh/ssh-key-parser.js' + +// โ”€โ”€ Test helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function writeU32(value: number): Buffer { + const buf = Buffer.allocUnsafe(4) + buf.writeUInt32BE(value, 0) + return buf +} + +/** + * A real Ed25519 private key in OpenSSH native format (unencrypted). + * Generated with: ssh-keygen -t ed25519 -f /tmp/brv_test_key -N "" -C "test@example.com" + * This key is used ONLY for unit testing โ€” never for any real credentials. + * Fingerprint: SHA256:R573at4sJuUgWnT+H8ivsX1khl0dKCW9KzJwDz00nmg + */ +const TEST_OPENSSH_ED25519_KEY = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('ssh-key-parser', () => { +describe('probeSSHKey()', () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'brv-ssh-test-')) + }) + + it('returns {exists: false} for non-existent file', async () => { + const result = await probeSSHKey(join(tempDir, 'missing_key')) + expect(result).to.deep.equal({exists: false}) + }) + + it('returns {exists: true, needsPassphrase: false} for unencrypted OpenSSH key', async () => { + const keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) + const result = await probeSSHKey(keyPath) + expect(result.exists).to.be.true + if (!result.exists) throw new Error('unreachable') + expect(result.needsPassphrase).to.be.false + }) + + it('returns {exists: true, needsPassphrase: true} for encrypted OpenSSH key', async () => { + // Construct a minimal OpenSSH key with cipherName = 'aes256-ctr' to simulate encrypted key. + // Must include a valid public key blob + private key blob so parseOpenSSHKey doesn't crash. + const sshStr = (s: Buffer | string) => { + const b = Buffer.isBuffer(s) ? s : Buffer.from(s) + return Buffer.concat([writeU32(b.length), b]) + } + + const pubBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(Buffer.alloc(32, 0xaa))]) + + const buf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), // magic + sshStr('aes256-ctr'), // ciphername + sshStr('bcrypt'), // kdfname + sshStr(Buffer.alloc(0)), // kdfoptions (empty) + writeU32(1), // nkeys = 1 + sshStr(pubBlob), // public key blob + sshStr(Buffer.alloc(64, 0xbb)), // private key blob (encrypted placeholder) + ]) + const b64 = buf.toString('base64') + const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${b64}\n-----END OPENSSH PRIVATE KEY-----` + + const keyPath = join(tempDir, 'id_ed25519_enc') + writeFileSync(keyPath, pem, {mode: 0o600}) + + const result = await probeSSHKey(keyPath) + expect(result.exists).to.be.true + if (!result.exists) throw new Error('unreachable') + expect(result.needsPassphrase).to.be.true + }) +}) + +// โ”€โ”€ parseSSHPrivateKey tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('parseSSHPrivateKey()', () => { + let tempDir: string + let keyPath: string + + before(() => { + tempDir = mkdtempSync(join(tmpdir(), 'brv-ssh-parse-test-')) + keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) + }) + + it('returns a ParsedSSHKey with correct shape', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + + expect(parsed).to.have.keys(['fingerprint', 'keyType', 'privateKeyObject', 'publicKeyBlob']) + }) + + it('keyType is ssh-ed25519', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + expect(parsed.keyType).to.equal('ssh-ed25519') + }) + + it('fingerprint is SHA256:... format', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + expect(parsed.fingerprint).to.match(/^SHA256:[A-Za-z0-9+/]+$/) + }) + + it('publicKeyBlob starts with ssh-ed25519 keytype string (SSH wire format)', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + // First 4 bytes = length of "ssh-ed25519" = 11 + const len = parsed.publicKeyBlob.readUInt32BE(0) + const keyType = parsed.publicKeyBlob.subarray(4, 4 + len).toString() + expect(keyType).to.equal('ssh-ed25519') + }) + + it('privateKeyObject is a valid KeyObject that can sign', async () => { + const {sign} = await import('node:crypto') + const parsed = await parseSSHPrivateKey(keyPath) + + // Ed25519 uses sign(null, data, key) โ€” algorithm is implicit + const sig = sign(null, Buffer.from('test payload'), parsed.privateKeyObject) + expect(sig).to.be.instanceOf(Buffer) + expect(sig.length).to.be.greaterThan(0) + }) + + it('throws for missing file', async () => { + let threw = false + try { + await parseSSHPrivateKey('/nonexistent/key') + } catch { + threw = true + } + + expect(threw).to.be.true + }) +}) + +// โ”€โ”€ resolveHome tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('resolveHome()', () => { + it('replaces leading ~ with HOME', () => { + const home = process.env.HOME ?? '/home/user' + const result = resolveHome('~/.ssh/id_ed25519') + expect(result).to.equal(`${home}/.ssh/id_ed25519`) + }) + + it('replaces bare ~ with HOME', () => { + const home = process.env.HOME ?? '/home/user' + const result = resolveHome('~') + expect(result).to.equal(home) + }) + + it('does not modify absolute paths', () => { + const result = resolveHome('/home/user/.ssh/id_ed25519') + expect(result).to.equal('/home/user/.ssh/id_ed25519') + }) + + it('does not modify relative paths', () => { + const result = resolveHome('keys/id_ed25519') + expect(result).to.equal('keys/id_ed25519') + }) +}) +}) diff --git a/test/unit/infra/ssh/sshsig-signer.test.ts b/test/unit/infra/ssh/sshsig-signer.test.ts new file mode 100644 index 000000000..4f18bda3d --- /dev/null +++ b/test/unit/infra/ssh/sshsig-signer.test.ts @@ -0,0 +1,79 @@ +import {expect} from 'chai' +import {mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {parseSSHPrivateKey} from '../../../../src/server/infra/ssh/ssh-key-parser.js' +import {signCommitPayload} from '../../../../src/server/infra/ssh/sshsig-signer.js' + +/** + * Real Ed25519 key for testing โ€” NOT a production key. + * Generated with: ssh-keygen -t ed25519 -f /tmp/brv_test_key -N "" -C "test@example.com" + * Fingerprint: SHA256:R573at4sJuUgWnT+H8ivsX1khl0dKCW9KzJwDz00nmg + */ +const TEST_OPENSSH_ED25519_KEY = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + +describe('signCommitPayload()', () => { + let tempDir: string + let keyPath: string + + // Helper: parse key once and reuse across tests + let parsedKey: Awaited> + + before(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'brv-sshsig-test-')) + keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) + parsedKey = await parseSSHPrivateKey(keyPath) + }) + + it('returns an SSHSignatureResult with armored and raw fields', async () => { + const result = signCommitPayload('tree abc123\nauthor Test\n\ninitial commit\n', parsedKey) + expect(result).to.have.keys(['armored', 'raw']) + }) + + it('armored signature starts with -----BEGIN SSH SIGNATURE-----', async () => { + const result = signCommitPayload('test payload', parsedKey) + expect(result.armored).to.match(/^-----BEGIN SSH SIGNATURE-----/) + }) + + it('armored signature ends with -----END SSH SIGNATURE-----', async () => { + const result = signCommitPayload('test payload', parsedKey) + expect(result.armored.trim()).to.match(/-----END SSH SIGNATURE-----$/) + }) + + it('raw buffer starts with SSHSIG magic (6 bytes)', async () => { + const result = signCommitPayload('test payload', parsedKey) + const magic = result.raw.subarray(0, 6).toString() + expect(magic).to.equal('SSHSIG') + }) + + it('different payloads produce different signatures', async () => { + const r1 = signCommitPayload('payload one', parsedKey) + const r2 = signCommitPayload('payload two', parsedKey) + expect(r1.armored).to.not.equal(r2.armored) + }) + + it('produces a valid base64 body in the armored output', async () => { + const result = signCommitPayload('test', parsedKey) + const lines = result.armored.split('\n') + // Remove BEGIN/END headers and join + const bodyLines = lines.filter( + (l) => !l.startsWith('-----') && l.trim().length > 0, + ) + const b64 = bodyLines.join('') + // Valid base64 should be decodable + expect(() => Buffer.from(b64, 'base64')).to.not.throw() + }) + + it('raw buffer is a Buffer', async () => { + const result = signCommitPayload('test', parsedKey) + expect(result.raw).to.be.instanceOf(Buffer) + }) +}) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index bb5936278..922c92d40 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -33,6 +33,7 @@ import { type IVcBranchRequest, type IVcBranchResponse, type IVcCheckoutResponse, + type IVcConfigResponse, type IVcFetchResponse, type IVcMergeRequest, type IVcMergeResponse, @@ -702,7 +703,7 @@ describe('VcHandler', () => { } }) - it('should throw VcError USER_NOT_CONFIGURED with pre-filled hint when logged in', async () => { + it('should resolve author from auth token when config is missing and user is logged in', async () => { const deps = makeDeps(sandbox, projectPath) deps.gitService.isInitialized.resolves(true) deps.gitService.status.resolves({ @@ -722,16 +723,11 @@ describe('VcHandler', () => { deps.tokenStore.load.resolves(mockToken) makeVcHandler(deps).setup() - try { - await deps.requestHandlers[VcEvents.COMMIT]({message: 'test'}, CLIENT_ID) - expect.fail('Expected error') - } catch (error) { - expect(error).to.be.instanceOf(VcError) - if (error instanceof VcError) { - expect(error.code).to.equal(VcErrorCode.USER_NOT_CONFIGURED) - expect(error.message).to.include('login@example.com') - } - } + await deps.requestHandlers[VcEvents.COMMIT]({message: 'test'}, CLIENT_ID) + + expect(deps.gitService.commit.calledOnce).to.be.true + const commitArgs = deps.gitService.commit.firstCall.args[0] + expect(commitArgs.author).to.deep.equal({email: 'login@example.com', name: 'login@example.com'}) }) it('should throw VcError GIT_NOT_INITIALIZED when git not initialized', async () => { @@ -816,6 +812,47 @@ describe('VcHandler', () => { } } }) + + it('should import git signing config across OS and strip .pub extension', async () => { + const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-config-')) + + // Create a real git repository to satisfy execFile('git') in the handler + mkdirSync(realProjectPath, {recursive: true}) + const {execSync} = await import('node:child_process') + execSync('git init', {cwd: realProjectPath}) + + // Set up fake keys + const keyPath = join(realProjectPath, 'fake_key') + const pubPath = `${keyPath}.pub` + // A structurally valid fake ed25519 openssh private key so parseSSHPrivateKey can probe it + const fakePrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBEVnO2XhZlYg1Z3TzT3XwB2YvM/XQYQnZQY1X/sVq1HQAAAJB6q16Aeqte +gAAAAAtzc2gtZWQyNTUxOQAAACBEVnO2XhZlYg1Z3TzT3XwB2YvM/XQYQnZQY1X/sVq1HQ +AAAEDe9Y3Z4YwZQy0YvTz/Q0ZQY1X/sVq1HQZWYg1Z3TzT3XwB2YvM/XQYQnZQY1X/sVq1 +HQBEVnO2XhZlYg1Z3TzT3XwB2YvM/XQYQnZQY1X/sVq1HQBEVnO2XhZlYg1Z3TzT3XwB2Y +vM/XQYQnZQY1X/sVq1HQAAABF0ZXN0QGV4YW1wbGUuY29tAQI= +-----END OPENSSH PRIVATE KEY-----` + writeFileSync(keyPath, fakePrivateKey, {mode: 0o600}) + writeFileSync(pubPath, 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIERWc7ZeFmViDVndPNPdfAHZi8z9dBhCdlBjVf+xWrUd', {mode: 0o644}) + + // Emulate git configuration where the user pointed to the .pub file + execSync(`git config user.signingkey "${pubPath}"`, {cwd: realProjectPath}) + execSync(`git config commit.gpgsign true`, {cwd: realProjectPath}) + + const deps = makeDeps(sandbox, realProjectPath) + deps.vcGitConfigStore.get.resolves({}) + makeVcHandler(deps).setup() + + const result = await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true, key: 'user.signingkey'}, CLIENT_ID) as IVcConfigResponse + + expect(deps.vcGitConfigStore.set.calledOnce).to.be.true + const savedConfig = deps.vcGitConfigStore.set.firstCall.args[1] + expect(savedConfig.commitSign).to.be.true + // Should strip .pub and save the private key path + expect(savedConfig.signingKey).to.equal(keyPath) + expect(result.value).to.equal(keyPath) + }) }) describe('handlePush', () => { @@ -3015,6 +3052,7 @@ describe('VcHandler', () => { try { deps.gitService.listBranches.resolves([{isCurrent: false, isRemote: false, name: 'feature'}]) deps.vcGitConfigStore.get.resolves() + deps.tokenStore.load.resolves() makeVcHandler(deps).setup() try { From fb6667d53a8f9ab11cf7d0b992ce9439f8a0ab37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Thu, 16 Apr 2026 20:23:13 +0700 Subject: [PATCH 02/46] fix(vc): address PR #435 review issues in SSH commit signing (ENG-2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - Move parseSSHPrivateKey/probeSSHKey/resolveHome to src/shared/ssh/ to fix oclif/ โ†’ server/ import boundary violation - Detect encrypted OpenSSH keys in resolveSigningKey and throw non-retryable SIGNING_KEY_NOT_SUPPORTED instead of looping PASSPHRASE_REQUIRED prompts - Fix RSA signing: use sign('sha512',...) and blob key-type 'rsa-sha2-512' instead of null/'ssh-rsa' (per PROTOCOL.sshsig spec) Medium: - Fix shouldSign default: signingKey alone no longer auto-enables signing (matches git behaviour โ€” commit.sign must be explicitly set) - Add NotAuthenticatedError guard to SigningKeyHandler.createService() - Add missing tests: signing-key-handler, http-signing-key-service, handleImportGitSigning edge cases Minor: - Replace deprecated Buffer.slice() with Buffer.subarray() in ssh-agent-signer - Deduplicate sshStr helper to module scope in ssh-key-parser - Clarify 30-minute TTL default in SigningKeyCache JSDoc - Use result.signed from IVcCommitResponse for ๐Ÿ” indicator (shows when signing is triggered via config, not only via --sign flag) - Replace IVcConfigRequest with discriminated union โ€” key is no longer spuriously required when importGitSigning:true Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/oclif/commands/signing-key/add.ts | 2 +- src/oclif/commands/vc/commit.ts | 2 +- src/oclif/commands/vc/config.ts | 4 +- src/server/infra/ssh/signing-key-cache.ts | 8 + src/server/infra/ssh/ssh-agent-signer.ts | 8 +- src/server/infra/ssh/ssh-key-parser.ts | 385 +----------------- src/server/infra/ssh/sshsig-signer.ts | 16 +- src/server/infra/ssh/types.ts | 46 +-- .../transport/handlers/signing-key-handler.ts | 5 +- .../infra/transport/handlers/vc-handler.ts | 29 +- src/shared/ssh/index.ts | 9 + src/shared/ssh/key-parser.ts | 376 +++++++++++++++++ src/shared/ssh/types.ts | 39 ++ src/shared/transport/events/vc-events.ts | 16 +- .../iam/http-signing-key-service.test.ts | 140 +++++++ test/unit/infra/ssh/ssh-key-parser.test.ts | 26 ++ test/unit/infra/ssh/sshsig-signer.test.ts | 83 ++++ .../handlers/signing-key-handler.test.ts | 175 ++++++++ .../transport/handlers/vc-handler.test.ts | 129 +++++- 19 files changed, 1056 insertions(+), 442 deletions(-) create mode 100644 src/shared/ssh/index.ts create mode 100644 src/shared/ssh/key-parser.ts create mode 100644 src/shared/ssh/types.ts create mode 100644 test/unit/infra/iam/http-signing-key-service.test.ts create mode 100644 test/unit/infra/transport/handlers/signing-key-handler.test.ts diff --git a/src/oclif/commands/signing-key/add.ts b/src/oclif/commands/signing-key/add.ts index 3c0c566f0..54c379f65 100644 --- a/src/oclif/commands/signing-key/add.ts +++ b/src/oclif/commands/signing-key/add.ts @@ -1,7 +1,7 @@ import {Command, Flags} from '@oclif/core' import {readFileSync} from 'node:fs' -import {parseSSHPrivateKey, resolveHome} from '../../../server/infra/ssh/index.js' +import {parseSSHPrivateKey, resolveHome} from '../../../shared/ssh/index.js' import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index e45a41e17..490c7631d 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -49,7 +49,7 @@ public static strict = false client.requestWithAck(VcEvents.COMMIT, payload), ) - const sigIndicator = sign === true ? ' ๐Ÿ”' : '' + const sigIndicator = result.signed ? ' ๐Ÿ”' : '' this.log(`[${result.sha.slice(0, 7)}] ${result.message}${sigIndicator}`) } catch (error) { // Passphrase required โ€” prompt and retry (capped) diff --git a/src/oclif/commands/vc/config.ts b/src/oclif/commands/vc/config.ts index ed50a6adc..15824c1fa 100644 --- a/src/oclif/commands/vc/config.ts +++ b/src/oclif/commands/vc/config.ts @@ -1,7 +1,7 @@ import {Args, Command, Flags} from '@oclif/core' import {existsSync, readFileSync} from 'node:fs' -import {parseSSHPrivateKey, resolveHome} from '../../../server/infra/ssh/index.js' +import {parseSSHPrivateKey, resolveHome} from '../../../shared/ssh/index.js' import {isVcConfigKey, type IVcConfigResponse, type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' @@ -74,7 +74,7 @@ public static flags = { private async runImport(): Promise { try { const result = await withDaemonRetry(async (client) => - client.requestWithAck(VcEvents.CONFIG, {importGitSigning: true, key: 'user.signingkey'}), + client.requestWithAck(VcEvents.CONFIG, {importGitSigning: true}), ) if (result.hint) this.log(` ${result.hint}`) diff --git a/src/server/infra/ssh/signing-key-cache.ts b/src/server/infra/ssh/signing-key-cache.ts index 795082546..5c0aaa765 100644 --- a/src/server/infra/ssh/signing-key-cache.ts +++ b/src/server/infra/ssh/signing-key-cache.ts @@ -23,6 +23,14 @@ export class SigningKeyCache { private readonly cache = new Map() private readonly ttlMs: number + /** + * @param ttlMs - Cache TTL in milliseconds. + * Default: 30 minutes. Rationale: a user signing many commits in one session + * should not need to re-parse the key file on every commit. 30 minutes balances + * convenience against the window in which a compromised daemon process could use + * the cached key object. The passphrase itself is never stored โ€” only the opaque + * crypto.KeyObject produced after decryption. + */ constructor(ttlMs: number = 30 * 60 * 1000) { this.ttlMs = ttlMs } diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index c1858d97b..bd5f8063d 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -58,12 +58,12 @@ class SshAgentClient { for (let i = 0; i < count; i++) { const blobLen = readUInt32(response, offset) offset += 4 - const blob = response.slice(offset, offset + blobLen) + const blob = response.subarray(offset, offset + blobLen) offset += blobLen const commentLen = readUInt32(response, offset) offset += 4 - const comment = response.slice(offset, offset + commentLen).toString('utf8') + const comment = response.subarray(offset, offset + commentLen).toString('utf8') offset += commentLen // Compute SHA256 fingerprint to match against our key @@ -112,7 +112,7 @@ class SshAgentClient { } if (accumulated.length >= 4 + responseLen) { - settle(() => resolve(accumulated.slice(4, 4 + responseLen))) + settle(() => resolve(accumulated.subarray(4, 4 + responseLen))) } } }) @@ -154,7 +154,7 @@ class SshAgentClient { // Response: byte SSH_AGENT_SIGN_RESPONSE + string(signature) const sigLen = readUInt32(response, 1) - return response.slice(5, 5 + sigLen) + return response.subarray(5, 5 + sigLen) } } diff --git a/src/server/infra/ssh/ssh-key-parser.ts b/src/server/infra/ssh/ssh-key-parser.ts index e28ea1527..aad221554 100644 --- a/src/server/infra/ssh/ssh-key-parser.ts +++ b/src/server/infra/ssh/ssh-key-parser.ts @@ -1,376 +1,9 @@ -import {createHash, createPrivateKey, createPublicKey} from 'node:crypto' -import {constants} from 'node:fs' -import {access, readFile} from 'node:fs/promises' -import {homedir} from 'node:os' - -import type {ParsedSSHKey, SSHKeyProbe, SSHKeyType} from './types.js' - -// โ”€โ”€ OpenSSH private key format parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// Spec: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key -// -// Binary layout: -// "openssh-key-v1\0" (magic) -// string ciphername ("none" if unencrypted) -// string kdfname ("none" if unencrypted) -// string kdfoptions (empty if unencrypted) -// uint32 nkeys (number of keys, usually 1) -// string pubkey (SSH wire-format public key) -// string private_keys (encrypted or plaintext private key data) -// -// Private key data (plaintext, nkeys=1): -// uint32 check1 -// uint32 check2 (must equal check1) -// string keytype (e.g., "ssh-ed25519") -// [key-type-specific private key fields] -// string comment -// [padding bytes: 1,2,3,...] - -const OPENSSH_MAGIC = 'openssh-key-v1\0' - -const VALID_SSH_KEY_TYPES: ReadonlySet = new Set([ - 'ecdsa-sha2-nistp256', - 'ecdsa-sha2-nistp384', - 'ecdsa-sha2-nistp521', - 'ssh-ed25519', - 'ssh-rsa', -]) - -/** Read a uint32 big-endian from a buffer at offset; returns [value, newOffset] */ -function readUInt32(buf: Buffer, offset: number): [number, number] { - return [buf.readUInt32BE(offset), offset + 4] -} - -/** Read an SSH wire-format length-prefixed string; returns [Buffer, newOffset] */ -function readSSHString(buf: Buffer, offset: number): [Buffer, number] { - const [len, afterLen] = readUInt32(buf, offset) - return [buf.subarray(afterLen, afterLen + len), afterLen + len] -} - -/** Parse the binary OpenSSH private key format (unencrypted only). */ -function parseOpenSSHKey(raw: string): { - cipherName: string - keyType: SSHKeyType - privateKeyBlob: Buffer - publicKeyBlob: Buffer -} { - // Strip PEM armor - const b64 = raw - .replace('-----BEGIN OPENSSH PRIVATE KEY-----', '') - .replace('-----END OPENSSH PRIVATE KEY-----', '') - .replaceAll(/\s+/g, '') - const buf = Buffer.from(b64, 'base64') - - // Verify magic - const magic = buf.subarray(0, OPENSSH_MAGIC.length).toString() - if (magic !== OPENSSH_MAGIC) { - throw new Error('Not an OpenSSH private key (wrong magic bytes)') - } - - let offset = OPENSSH_MAGIC.length - - // ciphername - let cipherNameBuf: Buffer - ;[cipherNameBuf, offset] = readSSHString(buf, offset) - const cipherName = cipherNameBuf.toString() - - // kdfname (skip value โ€” only offset matters) - ;[, offset] = readSSHString(buf, offset) - - // kdfoptions (skip value) - ;[, offset] = readSSHString(buf, offset) - - // nkeys - let nkeys: number - ;[nkeys, offset] = readUInt32(buf, offset) - if (nkeys !== 1) { - throw new Error(`OpenSSH key file contains ${nkeys} keys; only single-key files are supported`) - } - - // public key blob (SSH wire format) - let publicKeyBlob: Buffer - ;[publicKeyBlob, offset] = readSSHString(buf, offset) - - // private key blob (may be encrypted) - let privateKeyBlob: Buffer - ;[privateKeyBlob, offset] = readSSHString(buf, offset) - - // Read key type from public key blob to identify the key - const [keyTypeBuf] = readSSHString(publicKeyBlob, 0) - const keyTypeStr = keyTypeBuf.toString() - if (!VALID_SSH_KEY_TYPES.has(keyTypeStr)) { - throw new Error(`Unknown SSH key type: '${keyTypeStr}'`) - } - - const keyType = keyTypeStr as SSHKeyType - - return {cipherName, keyType, privateKeyBlob, publicKeyBlob} -} - -/** - * Convert an OpenSSH Ed25519 private key blob to a Node.js-loadable format. - * - * Ed25519 private key blob layout (plaintext): - * uint32 check1 - * uint32 check2 - * string "ssh-ed25519" - * string pubkey (32 bytes) - * string privkey (64 bytes: 32-byte seed + 32-byte pubkey) - * string comment - * padding bytes - */ -function opensshEd25519ToNodeKey(privateKeyBlob: Buffer): { - privateKeyPkcs8: Buffer - publicKeyBlob: Buffer -} { - let offset = 0 - - // check1 and check2 must match (used to verify decryption) - const [check1] = readUInt32(privateKeyBlob, offset) - offset += 4 - const [check2] = readUInt32(privateKeyBlob, offset) - offset += 4 - - if (check1 !== check2) { - throw new Error('OpenSSH key decryption check failed (wrong passphrase?)') - } - - // key type - let keyTypeBuf: Buffer - ;[keyTypeBuf, offset] = readSSHString(privateKeyBlob, offset) - const keyType = keyTypeBuf.toString() - - if (keyType !== 'ssh-ed25519') { - throw new Error(`Expected ssh-ed25519 key type, got: ${keyType}`) - } - - // public key (32 bytes) - let pubKeyBytes: Buffer - ;[pubKeyBytes, offset] = readSSHString(privateKeyBlob, offset) - - // private key (64 bytes: first 32 = seed) - let privKeyBytes: Buffer - ;[privKeyBytes, offset] = readSSHString(privateKeyBlob, offset) - - // The Ed25519 "private key" in OpenSSH format is the 64-byte concatenation - // of: seed (32 bytes) + public key (32 bytes). - // Node.js needs a DER-encoded ASN.1 PKCS8 structure for Ed25519. - // - // PKCS8 for Ed25519: - // SEQUENCE { - // INTEGER 0 (version) - // SEQUENCE { OID 1.3.101.112 (id-EdDSA) } - // OCTET STRING wrapping OCTET STRING (32-byte seed) - // } - const seed = privKeyBytes.subarray(0, 32) - - // ASN.1 encoding for Ed25519 private key in PKCS8 format - // This is the known fixed ASN.1 header for Ed25519 PKCS8 - const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex') - const pkcs8Der = Buffer.concat([pkcs8Header, seed]) - - // SSH wire format public key blob: string("ssh-ed25519") + string(32-byte-pubkey) - function sshStr(data: Buffer | string): Buffer { - const b = Buffer.isBuffer(data) ? data : Buffer.from(data) - const len = Buffer.allocUnsafe(4) - len.writeUInt32BE(b.length, 0) - return Buffer.concat([len, b]) - } - - const publicKeyBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(pubKeyBytes)]) - - return {privateKeyPkcs8: pkcs8Der, publicKeyBlob} -} - -/** Detect whether a PEM key parsing error indicates an encrypted key needing a passphrase. */ -function isPassphraseError(err: unknown): boolean { - if (!(err instanceof Error)) return false - - // Node.js crypto errors expose an `code` property (e.g., 'ERR_OSSL_BAD_DECRYPT') - const code = 'code' in err && typeof (err as {code: unknown}).code === 'string' - ? (err as {code: string}).code - : '' - if (code.includes('ERR_OSSL') && code.includes('DECRYPT')) return true - - // Fallback: string matching for compatibility across Node.js/OpenSSL versions - const msg = err.message.toLowerCase() - return msg.includes('bad decrypt') || msg.includes('passphrase') || msg.includes('bad password') -} - -// โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -/** - * Check if a key file exists and whether it requires a passphrase. - * Does NOT load the private key material beyond the initial probe. - */ -export async function probeSSHKey(keyPath: string): Promise { - try { - await access(keyPath, constants.R_OK) - } catch { - return {exists: false} - } - - try { - const raw = await readFile(keyPath, 'utf8') - - if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { - // OpenSSH format: check cipherName field - const parsed = parseOpenSSHKey(raw) - return {exists: true, needsPassphrase: parsed.cipherName !== 'none'} - } - - // PEM/PKCS8 format (RSA, ECDSA with traditional headers) - createPrivateKey({format: 'pem', key: raw}) - return {exists: true, needsPassphrase: false} - } catch (error: unknown) { - if (isPassphraseError(error)) { - return {exists: true, needsPassphrase: true} - } - - throw error - } -} - -/** - * Parse an SSH private key file into a usable signing key. - * Supports: - * - OpenSSH native format (Ed25519 only in v1; RSA/ECDSA to follow) - * - Standard PEM (PKCS#8, traditional RSA/ECDSA) - * - * Throws if passphrase is required but not provided, or if format is unsupported. - */ -export async function parseSSHPrivateKey( - keyPath: string, - passphrase?: string, -): Promise { - const raw = await readFile(keyPath, 'utf8') - - // โ”€โ”€ OpenSSH native format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { - const {cipherName, keyType, privateKeyBlob} = parseOpenSSHKey(raw) - - if (cipherName !== 'none') { - if (!passphrase) { - throw new Error('Passphrase required for encrypted key') - } - - // Encrypted OpenSSH keys require decryption before parsing. - // For now, throw a clear error โ€” encrypted OpenSSH key support - // requires AES-256-CTR + bcrypt KDF implementation (out of scope for v1 spike). - throw new Error( - 'Encrypted OpenSSH private keys are not yet supported. ' + - 'Please use an unencrypted key or load it via ssh-agent.', - ) - } - - if (keyType !== 'ssh-ed25519') { - throw new Error( - `Unsupported OpenSSH key type: ${keyType}. Only ssh-ed25519 is supported in v1.`, - ) - } - - const {privateKeyPkcs8, publicKeyBlob: sshPublicKeyBlob} = - opensshEd25519ToNodeKey(privateKeyBlob) - - const privateKeyObject = createPrivateKey({ - format: 'der', - key: privateKeyPkcs8, - type: 'pkcs8', - }) - - const fingerprint = computeFingerprint(sshPublicKeyBlob) - - return { - fingerprint, - keyType, - privateKeyObject, - publicKeyBlob: sshPublicKeyBlob, - } - } - - // โ”€โ”€ Standard PEM format (PKCS8, RSA, ECDSA) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const privateKeyObject = createPrivateKey({ - format: 'pem', - key: raw, - ...(passphrase ? {passphrase} : {}), - }) - - const publicKey = createPublicKey(privateKeyObject) - - // For non-Ed25519 keys in standard PEM, derive SSH wire format manually - const asymKeyType = privateKeyObject.asymmetricKeyType - let publicKeyBlob: Buffer - let keyType: SSHKeyType - - if (asymKeyType === 'ed25519') { - const derPub = publicKey.export({format: 'der', type: 'spki'}) as Buffer - // Ed25519 SPKI DER = 12-byte ASN.1 header + 32-byte raw public key - const rawPubBytes = derPub.subarray(12) - function sshStr(data: Buffer | string): Buffer { - const b = Buffer.isBuffer(data) ? data : Buffer.from(data) - const len = Buffer.allocUnsafe(4) - len.writeUInt32BE(b.length, 0) - return Buffer.concat([len, b]) - } - - keyType = 'ssh-ed25519' - publicKeyBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(rawPubBytes)]) - } else { - throw new Error(`Unsupported key type for PEM parsing: ${asymKeyType}`) - } - - const fingerprint = computeFingerprint(publicKeyBlob) - - return {fingerprint, keyType, privateKeyObject, publicKeyBlob} -} - -/** Compute SHA256 fingerprint from SSH wire-format public key blob. */ -export function computeFingerprint(publicKeyBlob: Buffer): string { - const hash = createHash('sha256').update(publicKeyBlob).digest('base64').replace(/=+$/, '') - return `SHA256:${hash}` -} - -/** - * Attempt to extract public key metadata (fingerprint and keyType) from a key path, - * checking for a .pub file first, then attempting to parse an OpenSSH private key - * (which contains the public key even if the private key is encrypted). - */ -export async function getPublicKeyMetadata(keyPath: string): Promise { - const pubPath = keyPath.endsWith('.pub') ? keyPath : `${keyPath}.pub` - try { - const rawPub = await readFile(pubPath, 'utf8') - const parts = rawPub.trim().split(' ') - if (parts.length >= 2) { - const keyType = parts[0] - const blob = Buffer.from(parts[1], 'base64') - return {fingerprint: computeFingerprint(blob), keyType} - } - } catch { - // Ignore error, fallback to private key - } - - try { - const raw = await readFile(keyPath, 'utf8') - if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { - const parsed = parseOpenSSHKey(raw) - return { - fingerprint: computeFingerprint(parsed.publicKeyBlob), - keyType: parsed.keyType, - } - } - } catch { - return null - } - - return null -} - -/** - * Resolve ~ to the user's home directory in a key path. - */ -export function resolveHome(keyPath: string): string { - if (keyPath.startsWith('~/') || keyPath === '~') { - return keyPath.replace('~', homedir()) - } - - return keyPath -} +// All implementations have moved to src/shared/ssh/key-parser.ts. +// Re-export everything so existing server imports (ssh-agent-signer, vc-handler, etc.) continue to work. +export { + computeFingerprint, + getPublicKeyMetadata, + parseSSHPrivateKey, + probeSSHKey, + resolveHome, +} from '../../../shared/ssh/key-parser.js' diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts index 80d415310..346a38e15 100644 --- a/src/server/infra/ssh/sshsig-signer.ts +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -47,16 +47,18 @@ export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignat ]) // 3. Sign the signed data with the private key - // Ed25519: sign(null, data, key) โ€” algorithm is implicit - // RSA: sign('sha512', data, key) โ€” must specify hash - // ECDSA: sign(null, data, key) โ€” algorithm follows curve - const rawSignature = sign(null, signedData, key.privateKeyObject) + // Ed25519: sign(null, data, key) โ€” algorithm is implicit in the key + // RSA: sign('sha512', data, key) โ€” must specify hash explicitly + // ECDSA: sign(null, data, key) โ€” algorithm follows the curve + const isRsa = key.keyType === 'ssh-rsa' + const rawSignature = sign(isRsa ? 'sha512' : null, signedData, key.privateKeyObject) // 4. Build the SSH signature blob (key-type-specific wrapper) - // Ed25519: string("ssh-ed25519") + string(64-byte-sig) - // RSA: string("rsa-sha2-512") + string(rsa-sig) + // Ed25519: string("ssh-ed25519") + string(64-byte-sig) + // RSA: string("rsa-sha2-512") + string(rsa-sig) โ† NOT "ssh-rsa" // ECDSA: string("ecdsa-sha2-nistp256") + string(ecdsa-sig) - const signatureBlob = Buffer.concat([sshString(key.keyType), sshString(rawSignature)]) + const blobKeyType = isRsa ? 'rsa-sha2-512' : key.keyType + const signatureBlob = Buffer.concat([sshString(blobKeyType), sshString(rawSignature)]) // 5. Build the full sshsig binary envelope per PROTOCOL.sshsig ยง3 const versionBuf = Buffer.allocUnsafe(4) diff --git a/src/server/infra/ssh/types.ts b/src/server/infra/ssh/types.ts index 9af2d1602..0ff35cb1a 100644 --- a/src/server/infra/ssh/types.ts +++ b/src/server/infra/ssh/types.ts @@ -1,37 +1,9 @@ -import type * as crypto from 'node:crypto' - -export type SSHKeyType = - | 'ecdsa-sha2-nistp256' - | 'ecdsa-sha2-nistp384' - | 'ecdsa-sha2-nistp521' - | 'ssh-ed25519' - | 'ssh-rsa' - -export interface ParsedSSHKey { - /** SHA256 fingerprint โ€” used for display and matching with IAM */ - fingerprint: string - /** Key type identifier (e.g., 'ssh-ed25519') */ - keyType: SSHKeyType - /** Node.js crypto KeyObject โ€” opaque, not extractable */ - privateKeyObject: crypto.KeyObject - /** Raw public key blob in SSH wire format (for embedding in sshsig) */ - publicKeyBlob: Buffer -} - -export interface SSHSignatureResult { - /** Armored SSH signature (-----BEGIN SSH SIGNATURE----- ... -----END SSH SIGNATURE-----) */ - armored: string - /** Raw sshsig binary (before base64 armoring) */ - raw: Buffer -} - -export interface SSHKeyProbeResult { - exists: false -} - -export interface SSHKeyProbeResultFound { - exists: true - needsPassphrase: boolean -} - -export type SSHKeyProbe = SSHKeyProbeResult | SSHKeyProbeResultFound +// Re-exported from shared so server modules continue to work via their existing import paths. +export type { + ParsedSSHKey, + SSHKeyProbe, + SSHKeyProbeResult, + SSHKeyProbeResultFound, + SSHKeyType, + SSHSignatureResult, +} from '../../../shared/ssh/types.js' diff --git a/src/server/infra/transport/handlers/signing-key-handler.ts b/src/server/infra/transport/handlers/signing-key-handler.ts index 348c9b1f6..6cdd4f073 100644 --- a/src/server/infra/transport/handlers/signing-key-handler.ts +++ b/src/server/infra/transport/handlers/signing-key-handler.ts @@ -8,6 +8,7 @@ import { type SigningKeyItem, VcEvents, } from '../../../../shared/transport/events/vc-events.js' +import {NotAuthenticatedError} from '../../../core/domain/errors/task-error.js' import {AuthenticatedHttpClient} from '../../http/authenticated-http-client.js' import {HttpSigningKeyService} from '../../iam/http-signing-key-service.js' @@ -56,8 +57,8 @@ export class SigningKeyHandler { private async createService(): Promise { const token = await this.tokenStore.load() - const sessionKey = token?.sessionKey ?? '' - const httpClient = new AuthenticatedHttpClient(sessionKey) + if (!token?.isValid()) throw new NotAuthenticatedError() + const httpClient = new AuthenticatedHttpClient(token.sessionKey) return new HttpSigningKeyService(httpClient, this.iamBaseUrl) } diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index cce95887a..1feec47eb 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -550,8 +550,9 @@ export class VcHandler { // Determine whether to sign this commit: // data.sign=true โ†’ force sign // data.sign=false โ†’ force no-sign - // undefined โ†’ use config.commitSign, or auto-sign if signingKey is configured - const shouldSign = data.sign ?? config?.commitSign ?? config?.signingKey !== undefined + // undefined โ†’ use config.commitSign (false by default โ€” matches git behaviour where + // setting user.signingKey alone does NOT enable auto-signing) + const shouldSign = data.sign ?? config?.commitSign ?? false let onSign: ((payload: string) => Promise) | undefined @@ -589,7 +590,7 @@ export class VcHandler { ...(onSign ? {onSign} : {}), }) - return {message: commit.message, sha: commit.sha} + return {message: commit.message, sha: commit.sha, ...(shouldSign ? {signed: true} : {})} } private async handleConfig(data: IVcConfigRequest, clientId: string): Promise { @@ -1483,11 +1484,23 @@ export class VcHandler { ) } - if (probe.needsPassphrase && !passphrase) { - throw new VcError( - `SSH key at ${keyPath} requires a passphrase. Retry with your passphrase.`, - VcErrorCode.PASSPHRASE_REQUIRED, - ) + if (probe.needsPassphrase) { + // Encrypted OpenSSH native format (bcrypt KDF + AES cipher) is not supported for + // direct decryption. Tell the user to load the key into ssh-agent instead. + if (probe.opensshEncrypted) { + throw new VcError( + `Encrypted OpenSSH private keys are not supported for direct signing. ` + + `Load the key into ssh-agent first: ssh-add ${keyPath}`, + VcErrorCode.SIGNING_KEY_NOT_SUPPORTED, + ) + } + + if (!passphrase) { + throw new VcError( + `SSH key at ${keyPath} requires a passphrase. Retry with your passphrase.`, + VcErrorCode.PASSPHRASE_REQUIRED, + ) + } } const parsed = await parseSSHPrivateKey(keyPath, passphrase) diff --git a/src/shared/ssh/index.ts b/src/shared/ssh/index.ts new file mode 100644 index 000000000..9a1b2cecd --- /dev/null +++ b/src/shared/ssh/index.ts @@ -0,0 +1,9 @@ +export { + computeFingerprint, + getPublicKeyMetadata, + parseSSHPrivateKey, + probeSSHKey, + resolveHome, +} from './key-parser.js' + +export type {ParsedSSHKey, SSHKeyProbe, SSHKeyType, SSHSignatureResult} from './types.js' diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts new file mode 100644 index 000000000..7cba9e4fa --- /dev/null +++ b/src/shared/ssh/key-parser.ts @@ -0,0 +1,376 @@ +import {createHash, createPrivateKey, createPublicKey} from 'node:crypto' +import {constants} from 'node:fs' +import {access, readFile} from 'node:fs/promises' +import {homedir} from 'node:os' + +import type {ParsedSSHKey, SSHKeyProbe, SSHKeyType} from './types.js' + +// โ”€โ”€ OpenSSH private key format parser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Spec: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key +// +// Binary layout: +// "openssh-key-v1\0" (magic) +// string ciphername ("none" if unencrypted) +// string kdfname ("none" if unencrypted) +// string kdfoptions (empty if unencrypted) +// uint32 nkeys (number of keys, usually 1) +// string pubkey (SSH wire-format public key) +// string private_keys (encrypted or plaintext private key data) +// +// Private key data (plaintext, nkeys=1): +// uint32 check1 +// uint32 check2 (must equal check1) +// string keytype (e.g., "ssh-ed25519") +// [key-type-specific private key fields] +// string comment +// [padding bytes: 1,2,3,...] + +const OPENSSH_MAGIC = 'openssh-key-v1\0' + +/** Encode a value as an SSH wire-format length-prefixed string. */ +function sshStr(data: Buffer | string): Buffer { + const b = Buffer.isBuffer(data) ? data : Buffer.from(data) + const len = Buffer.allocUnsafe(4) + len.writeUInt32BE(b.length, 0) + return Buffer.concat([len, b]) +} + +const VALID_SSH_KEY_TYPES: ReadonlySet = new Set([ + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'ssh-ed25519', + 'ssh-rsa', +]) + +/** Read a uint32 big-endian from a buffer at offset; returns [value, newOffset] */ +function readUInt32(buf: Buffer, offset: number): [number, number] { + return [buf.readUInt32BE(offset), offset + 4] +} + +/** Read an SSH wire-format length-prefixed string; returns [Buffer, newOffset] */ +function readSSHString(buf: Buffer, offset: number): [Buffer, number] { + const [len, afterLen] = readUInt32(buf, offset) + return [buf.subarray(afterLen, afterLen + len), afterLen + len] +} + +/** Parse the binary OpenSSH private key format (unencrypted only). */ +function parseOpenSSHKey(raw: string): { + cipherName: string + keyType: SSHKeyType + privateKeyBlob: Buffer + publicKeyBlob: Buffer +} { + // Strip PEM armor + const b64 = raw + .replace('-----BEGIN OPENSSH PRIVATE KEY-----', '') + .replace('-----END OPENSSH PRIVATE KEY-----', '') + .replaceAll(/\s+/g, '') + const buf = Buffer.from(b64, 'base64') + + // Verify magic + const magic = buf.subarray(0, OPENSSH_MAGIC.length).toString() + if (magic !== OPENSSH_MAGIC) { + throw new Error('Not an OpenSSH private key (wrong magic bytes)') + } + + let offset = OPENSSH_MAGIC.length + + // ciphername + let cipherNameBuf: Buffer + ;[cipherNameBuf, offset] = readSSHString(buf, offset) + const cipherName = cipherNameBuf.toString() + + // kdfname (skip value โ€” only offset matters) + ;[, offset] = readSSHString(buf, offset) + + // kdfoptions (skip value) + ;[, offset] = readSSHString(buf, offset) + + // nkeys + let nkeys: number + ;[nkeys, offset] = readUInt32(buf, offset) + if (nkeys !== 1) { + throw new Error(`OpenSSH key file contains ${nkeys} keys; only single-key files are supported`) + } + + // public key blob (SSH wire format) + let publicKeyBlob: Buffer + ;[publicKeyBlob, offset] = readSSHString(buf, offset) + + // private key blob (may be encrypted) + let privateKeyBlob: Buffer + ;[privateKeyBlob, offset] = readSSHString(buf, offset) + + // Read key type from public key blob to identify the key + const [keyTypeBuf] = readSSHString(publicKeyBlob, 0) + const keyTypeStr = keyTypeBuf.toString() + if (!VALID_SSH_KEY_TYPES.has(keyTypeStr)) { + throw new Error(`Unknown SSH key type: '${keyTypeStr}'`) + } + + const keyType = keyTypeStr as SSHKeyType + + return {cipherName, keyType, privateKeyBlob, publicKeyBlob} +} + +/** + * Convert an OpenSSH Ed25519 private key blob to a Node.js-loadable format. + * + * Ed25519 private key blob layout (plaintext): + * uint32 check1 + * uint32 check2 + * string "ssh-ed25519" + * string pubkey (32 bytes) + * string privkey (64 bytes: 32-byte seed + 32-byte pubkey) + * string comment + * padding bytes + */ +function opensshEd25519ToNodeKey(privateKeyBlob: Buffer): { + privateKeyPkcs8: Buffer + publicKeyBlob: Buffer +} { + let offset = 0 + + // check1 and check2 must match (used to verify decryption) + const [check1] = readUInt32(privateKeyBlob, offset) + offset += 4 + const [check2] = readUInt32(privateKeyBlob, offset) + offset += 4 + + if (check1 !== check2) { + throw new Error('OpenSSH key decryption check failed (wrong passphrase?)') + } + + // key type + let keyTypeBuf: Buffer + ;[keyTypeBuf, offset] = readSSHString(privateKeyBlob, offset) + const keyType = keyTypeBuf.toString() + + if (keyType !== 'ssh-ed25519') { + throw new Error(`Expected ssh-ed25519 key type, got: ${keyType}`) + } + + // public key (32 bytes) + let pubKeyBytes: Buffer + ;[pubKeyBytes, offset] = readSSHString(privateKeyBlob, offset) + + // private key (64 bytes: first 32 = seed) + let privKeyBytes: Buffer + ;[privKeyBytes, offset] = readSSHString(privateKeyBlob, offset) + + // The Ed25519 "private key" in OpenSSH format is the 64-byte concatenation + // of: seed (32 bytes) + public key (32 bytes). + // Node.js needs a DER-encoded ASN.1 PKCS8 structure for Ed25519. + // + // PKCS8 for Ed25519: + // SEQUENCE { + // INTEGER 0 (version) + // SEQUENCE { OID 1.3.101.112 (id-EdDSA) } + // OCTET STRING wrapping OCTET STRING (32-byte seed) + // } + const seed = privKeyBytes.subarray(0, 32) + + // ASN.1 encoding for Ed25519 private key in PKCS8 format + // This is the known fixed ASN.1 header for Ed25519 PKCS8 + const pkcs8Header = Buffer.from('302e020100300506032b657004220420', 'hex') + const pkcs8Der = Buffer.concat([pkcs8Header, seed]) + + // SSH wire format public key blob: string("ssh-ed25519") + string(32-byte-pubkey) + const publicKeyBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(pubKeyBytes)]) + + return {privateKeyPkcs8: pkcs8Der, publicKeyBlob} +} + +/** Detect whether a PEM key parsing error indicates an encrypted key needing a passphrase. */ +function isPassphraseError(err: unknown): boolean { + if (!(err instanceof Error)) return false + + // Node.js crypto errors expose an `code` property (e.g., 'ERR_OSSL_BAD_DECRYPT') + const code = 'code' in err && typeof (err as {code: unknown}).code === 'string' + ? (err as {code: string}).code + : '' + if (code.includes('ERR_OSSL') && code.includes('DECRYPT')) return true + + // Fallback: string matching for compatibility across Node.js/OpenSSL versions + const msg = err.message.toLowerCase() + return msg.includes('bad decrypt') || msg.includes('passphrase') || msg.includes('bad password') +} + +// โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Check if a key file exists and whether it requires a passphrase. + * Does NOT load the private key material beyond the initial probe. + */ +export async function probeSSHKey(keyPath: string): Promise { + try { + await access(keyPath, constants.R_OK) + } catch { + return {exists: false} + } + + try { + const raw = await readFile(keyPath, 'utf8') + + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + // OpenSSH format: check cipherName field + const parsed = parseOpenSSHKey(raw) + const needsPassphrase = parsed.cipherName !== 'none' + return { + exists: true, + needsPassphrase, + ...(needsPassphrase ? {opensshEncrypted: true} : {}), + } + } + + // PEM/PKCS8 format (RSA, ECDSA with traditional headers) + createPrivateKey({format: 'pem', key: raw}) + return {exists: true, needsPassphrase: false} + } catch (error: unknown) { + if (isPassphraseError(error)) { + return {exists: true, needsPassphrase: true} + } + + throw error + } +} + +/** + * Parse an SSH private key file into a usable signing key. + * Supports: + * - OpenSSH native format (Ed25519 only in v1; RSA/ECDSA to follow) + * - Standard PEM (PKCS#8, traditional RSA/ECDSA) + * + * Throws if passphrase is required but not provided, or if format is unsupported. + */ +export async function parseSSHPrivateKey( + keyPath: string, + passphrase?: string, +): Promise { + const raw = await readFile(keyPath, 'utf8') + + // โ”€โ”€ OpenSSH native format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + const {cipherName, keyType, privateKeyBlob} = parseOpenSSHKey(raw) + + if (cipherName !== 'none') { + if (!passphrase) { + throw new Error('Passphrase required for encrypted key') + } + + // Encrypted OpenSSH keys require decryption before parsing. + // For now, throw a clear error โ€” encrypted OpenSSH key support + // requires AES-256-CTR + bcrypt KDF implementation (out of scope for v1 spike). + throw new Error( + 'Encrypted OpenSSH private keys are not yet supported. ' + + 'Please use an unencrypted key or load it via ssh-agent.', + ) + } + + if (keyType !== 'ssh-ed25519') { + throw new Error( + `Unsupported OpenSSH key type: ${keyType}. Only ssh-ed25519 is supported in v1.`, + ) + } + + const {privateKeyPkcs8, publicKeyBlob: sshPublicKeyBlob} = + opensshEd25519ToNodeKey(privateKeyBlob) + + const privateKeyObject = createPrivateKey({ + format: 'der', + key: privateKeyPkcs8, + type: 'pkcs8', + }) + + const fingerprint = computeFingerprint(sshPublicKeyBlob) + + return { + fingerprint, + keyType, + privateKeyObject, + publicKeyBlob: sshPublicKeyBlob, + } + } + + // โ”€โ”€ Standard PEM format (PKCS8, RSA, ECDSA) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const privateKeyObject = createPrivateKey({ + format: 'pem', + key: raw, + ...(passphrase ? {passphrase} : {}), + }) + + const publicKey = createPublicKey(privateKeyObject) + + // For non-Ed25519 keys in standard PEM, derive SSH wire format manually + const asymKeyType = privateKeyObject.asymmetricKeyType + let publicKeyBlob: Buffer + let keyType: SSHKeyType + + if (asymKeyType === 'ed25519') { + const derPub = publicKey.export({format: 'der', type: 'spki'}) as Buffer + // Ed25519 SPKI DER = 12-byte ASN.1 header + 32-byte raw public key + const rawPubBytes = derPub.subarray(12) + + keyType = 'ssh-ed25519' + publicKeyBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(rawPubBytes)]) + } else { + throw new Error(`Unsupported key type for PEM parsing: ${asymKeyType}`) + } + + const fingerprint = computeFingerprint(publicKeyBlob) + + return {fingerprint, keyType, privateKeyObject, publicKeyBlob} +} + +/** Compute SHA256 fingerprint from SSH wire-format public key blob. */ +export function computeFingerprint(publicKeyBlob: Buffer): string { + const hash = createHash('sha256').update(publicKeyBlob).digest('base64').replace(/=+$/, '') + return `SHA256:${hash}` +} + +/** + * Attempt to extract public key metadata (fingerprint and keyType) from a key path, + * checking for a .pub file first, then attempting to parse an OpenSSH private key + * (which contains the public key even if the private key is encrypted). + */ +export async function getPublicKeyMetadata(keyPath: string): Promise { + const pubPath = keyPath.endsWith('.pub') ? keyPath : `${keyPath}.pub` + try { + const rawPub = await readFile(pubPath, 'utf8') + const parts = rawPub.trim().split(' ') + if (parts.length >= 2) { + const keyType = parts[0] + const blob = Buffer.from(parts[1], 'base64') + return {fingerprint: computeFingerprint(blob), keyType} + } + } catch { + // Ignore error, fallback to private key + } + + try { + const raw = await readFile(keyPath, 'utf8') + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + const parsed = parseOpenSSHKey(raw) + return { + fingerprint: computeFingerprint(parsed.publicKeyBlob), + keyType: parsed.keyType, + } + } + } catch { + return null + } + + return null +} + +/** + * Resolve ~ to the user's home directory in a key path. + */ +export function resolveHome(keyPath: string): string { + if (keyPath.startsWith('~/') || keyPath === '~') { + return keyPath.replace('~', homedir()) + } + + return keyPath +} diff --git a/src/shared/ssh/types.ts b/src/shared/ssh/types.ts new file mode 100644 index 000000000..a807db647 --- /dev/null +++ b/src/shared/ssh/types.ts @@ -0,0 +1,39 @@ +import type * as crypto from 'node:crypto' + +export type SSHKeyType = + | 'ecdsa-sha2-nistp256' + | 'ecdsa-sha2-nistp384' + | 'ecdsa-sha2-nistp521' + | 'ssh-ed25519' + | 'ssh-rsa' + +export interface ParsedSSHKey { + /** SHA256 fingerprint โ€” used for display and matching with IAM */ + fingerprint: string + /** Key type identifier (e.g., 'ssh-ed25519') */ + keyType: SSHKeyType + /** Node.js crypto KeyObject โ€” opaque, not extractable */ + privateKeyObject: crypto.KeyObject + /** Raw public key blob in SSH wire format (for embedding in sshsig) */ + publicKeyBlob: Buffer +} + +export interface SSHSignatureResult { + /** Armored SSH signature (-----BEGIN SSH SIGNATURE----- ... -----END SSH SIGNATURE-----) */ + armored: string + /** Raw sshsig binary (before base64 armoring) */ + raw: Buffer +} + +export interface SSHKeyProbeResult { + exists: false +} + +export interface SSHKeyProbeResultFound { + exists: true + needsPassphrase: boolean + /** True when the key is OpenSSH native format AND encrypted (bcrypt KDF + AES cipher). */ + opensshEncrypted?: boolean +} + +export type SSHKeyProbe = SSHKeyProbeResult | SSHKeyProbeResultFound diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index 15c669cad..c7265a570 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -35,6 +35,7 @@ export const VcErrorCode = { REMOTE_ALREADY_EXISTS: 'ERR_VC_REMOTE_ALREADY_EXISTS', SIGNING_KEY_NOT_CONFIGURED: 'ERR_VC_SIGNING_KEY_NOT_CONFIGURED', SIGNING_KEY_NOT_FOUND: 'ERR_VC_SIGNING_KEY_NOT_FOUND', + SIGNING_KEY_NOT_SUPPORTED: 'ERR_VC_SIGNING_KEY_NOT_SUPPORTED', UNCOMMITTED_CHANGES: 'ERR_VC_UNCOMMITTED_CHANGES', UNRELATED_HISTORIES: 'ERR_VC_UNRELATED_HISTORIES', USER_NOT_CONFIGURED: 'ERR_VC_USER_NOT_CONFIGURED', @@ -101,6 +102,8 @@ export interface IVcCommitRequest { export interface IVcCommitResponse { message: string sha: string + /** True when the commit was cryptographically signed with an SSH key. */ + signed?: boolean } export type VcConfigKey = 'commit.sign' | 'user.email' | 'user.name' | 'user.signingkey' @@ -116,15 +119,22 @@ export function isVcConfigKey(key: string): key is VcConfigKey { return VC_CONFIG_KEYS.includes(key) } -export interface IVcConfigRequest { - /** If true, import SSH signing config from local/global git config */ - importGitSigning?: boolean +/** Import SSH signing settings from local/global git config (no key/value needed). */ +export interface IVcConfigImportRequest { + importGitSigning: true +} + +/** Get or set a single config key. */ +export interface IVcConfigKeyRequest { + importGitSigning?: false /** Config key (e.g., 'user.email', 'user.signingkey') */ key: VcConfigKey /** Value to set. Omit for GET. For 'commit.sign': 'true' or 'false'. */ value?: string } +export type IVcConfigRequest = IVcConfigImportRequest | IVcConfigKeyRequest + export interface IVcConfigResponse { /** Optional display hint (e.g., fingerprint after setting signingkey) */ hint?: string diff --git a/test/unit/infra/iam/http-signing-key-service.test.ts b/test/unit/infra/iam/http-signing-key-service.test.ts new file mode 100644 index 000000000..b36e8bd9a --- /dev/null +++ b/test/unit/infra/iam/http-signing-key-service.test.ts @@ -0,0 +1,140 @@ +/** + * HttpSigningKeyService Unit Tests + */ + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {IHttpClient} from '../../../../src/server/core/interfaces/services/i-http-client.js' + +import {HttpSigningKeyService} from '../../../../src/server/infra/iam/http-signing-key-service.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const IAM_BASE_URL = 'https://iam.example.com' +const KEYS_PATH = '/api/v3/users/me/signing-keys' + +/* eslint-disable camelcase */ +const RAW_KEY = { + created_at: '2024-01-01T00:00:00Z', + fingerprint: 'SHA256:abc123', + id: 'key-id-1', + key_type: 'ssh-ed25519', + public_key: 'ssh-ed25519 AAAA... test@example.com', + title: 'My laptop', +} +/* eslint-enable camelcase */ + +describe('HttpSigningKeyService', () => { + let sandbox: SinonSandbox + let httpClient: Stubbed + let service: HttpSigningKeyService + + beforeEach(() => { + sandbox = createSandbox() + httpClient = { + delete: sandbox.stub().resolves(), + get: sandbox.stub(), + post: sandbox.stub(), + put: sandbox.stub().resolves(), + } + service = new HttpSigningKeyService(httpClient as unknown as IHttpClient, IAM_BASE_URL) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('addKey()', () => { + it('POSTs to the signing-keys endpoint with snake_case body', async () => { + httpClient.post.resolves({ + data: {signing_key: RAW_KEY}, // eslint-disable-line camelcase + success: true, + }) + + await service.addKey('My laptop', 'ssh-ed25519 AAAA...') + + expect(httpClient.post.calledOnce).to.be.true + const [url, body] = httpClient.post.firstCall.args + expect(url).to.equal(`${IAM_BASE_URL}${KEYS_PATH}`) + expect(body).to.deep.equal({ + public_key: 'ssh-ed25519 AAAA...', // eslint-disable-line camelcase + title: 'My laptop', + }) + }) + + it('maps snake_case API response to camelCase SigningKeyResource', async () => { + httpClient.post.resolves({ + data: {signing_key: RAW_KEY}, // eslint-disable-line camelcase + success: true, + }) + + const result = await service.addKey('My laptop', 'ssh-ed25519 AAAA...') + + expect(result).to.deep.equal({ + createdAt: '2024-01-01T00:00:00Z', + fingerprint: 'SHA256:abc123', + id: 'key-id-1', + keyType: 'ssh-ed25519', + lastUsedAt: undefined, + publicKey: 'ssh-ed25519 AAAA... test@example.com', + title: 'My laptop', + }) + }) + }) + + describe('listKeys()', () => { + it('GETs from the signing-keys endpoint', async () => { + httpClient.get.resolves({ + data: {signing_keys: [RAW_KEY]}, // eslint-disable-line camelcase + success: true, + }) + + await service.listKeys() + + expect(httpClient.get.calledOnce).to.be.true + expect(httpClient.get.firstCall.args[0]).to.equal(`${IAM_BASE_URL}${KEYS_PATH}`) + }) + + it('maps each key from snake_case to camelCase', async () => { + httpClient.get.resolves({ + data: {signing_keys: [RAW_KEY]}, // eslint-disable-line camelcase + success: true, + }) + + const keys = await service.listKeys() + + expect(keys).to.have.length(1) + expect(keys[0]).to.include({fingerprint: 'SHA256:abc123', keyType: 'ssh-ed25519'}) + }) + + it('returns empty array when signing_keys field is missing', async () => { + httpClient.get.resolves({ + data: {}, + success: true, + }) + + const keys = await service.listKeys() + + expect(keys).to.deep.equal([]) + }) + }) + + describe('removeKey()', () => { + it('DELETEs to the signing-keys/{id} endpoint', async () => { + await service.removeKey('key-id-1') + + expect(httpClient.delete.calledOnce).to.be.true + expect(httpClient.delete.firstCall.args[0]).to.equal(`${IAM_BASE_URL}${KEYS_PATH}/key-id-1`) + }) + }) + + describe('URL construction', () => { + it('strips trailing slash from base URL', () => { + const svc = new HttpSigningKeyService(httpClient as unknown as IHttpClient, 'https://iam.example.com/') + httpClient.get.resolves({data: {signing_keys: []}, success: true}) // eslint-disable-line camelcase + svc.listKeys() + expect(httpClient.get.firstCall.args[0]).to.equal(`https://iam.example.com${KEYS_PATH}`) + }) + }) +}) diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 8c94b9afb..4f8d681be 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -51,6 +51,32 @@ describe('probeSSHKey()', () => { expect(result.needsPassphrase).to.be.false }) + it('returns opensshEncrypted:true for encrypted OpenSSH-format key', async () => { + const sshStr = (s: Buffer | string) => { + const b = Buffer.isBuffer(s) ? s : Buffer.from(s) + return Buffer.concat([writeU32(b.length), b]) + } + + const pubBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(Buffer.alloc(32, 0xaa))]) + const buf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), + sshStr('aes256-ctr'), + sshStr('bcrypt'), + sshStr(Buffer.alloc(0)), + writeU32(1), + sshStr(pubBlob), + sshStr(Buffer.alloc(64, 0xbb)), + ]) + const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${buf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` + const keyPath = join(tempDir, 'id_openssh_enc2') + writeFileSync(keyPath, pem, {mode: 0o600}) + + const result = await probeSSHKey(keyPath) + expect(result.exists).to.be.true + if (!result.exists) throw new Error('unreachable') + expect(result.opensshEncrypted).to.be.true + }) + it('returns {exists: true, needsPassphrase: true} for encrypted OpenSSH key', async () => { // Construct a minimal OpenSSH key with cipherName = 'aes256-ctr' to simulate encrypted key. // Must include a valid public key blob + private key blob so parseOpenSSHKey doesn't crash. diff --git a/test/unit/infra/ssh/sshsig-signer.test.ts b/test/unit/infra/ssh/sshsig-signer.test.ts index 4f18bda3d..6aeb88da8 100644 --- a/test/unit/infra/ssh/sshsig-signer.test.ts +++ b/test/unit/infra/ssh/sshsig-signer.test.ts @@ -1,11 +1,44 @@ import {expect} from 'chai' +import {createHash, verify as cryptoVerify, generateKeyPairSync} from 'node:crypto' import {mkdtempSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' +import type {ParsedSSHKey} from '../../../../src/server/infra/ssh/types.js' + import {parseSSHPrivateKey} from '../../../../src/server/infra/ssh/ssh-key-parser.js' import {signCommitPayload} from '../../../../src/server/infra/ssh/sshsig-signer.js' +/** Parse an SSH wire-format length-prefixed string from a buffer. Returns [value, nextOffset]. */ +function readSSHString(buf: Buffer, offset: number): [Buffer, number] { + const len = buf.readUInt32BE(offset) + return [buf.subarray(offset + 4, offset + 4 + len), offset + 4 + len] +} + +/** + * Extract the signature blob key-type string and raw signature bytes from + * an armored sshsig output. Returns {keyType, rawSig}. + */ +function extractSigFromArmored(armored: string): {keyType: string; rawSig: Buffer} { + const lines = armored.split('\n') + const b64 = lines.filter((l) => !l.startsWith('-----') && l.trim().length > 0).join('') + const raw = Buffer.from(b64, 'base64') + + // Envelope layout (all length-prefixed SSH strings): + // 'SSHSIG' (6 bytes) + version uint32 (4 bytes) + pubkey + namespace + reserved + hash-algo + sig-blob + let offset = 10 // skip magic (6) + version (4) + ;[, offset] = readSSHString(raw, offset) // pubkey + ;[, offset] = readSSHString(raw, offset) // namespace + ;[, offset] = readSSHString(raw, offset) // reserved + ;[, offset] = readSSHString(raw, offset) // hash-algo + const [sigBlob] = readSSHString(raw, offset) // signature blob + + // Signature blob: string(key-type) + string(raw-sig) + const [keyTypeBuf, afterKeyType] = readSSHString(sigBlob, 0) + const [rawSig] = readSSHString(sigBlob, afterKeyType) + return {keyType: keyTypeBuf.toString(), rawSig} +} + /** * Real Ed25519 key for testing โ€” NOT a production key. * Generated with: ssh-keygen -t ed25519 -f /tmp/brv_test_key -N "" -C "test@example.com" @@ -76,4 +109,54 @@ describe('signCommitPayload()', () => { const result = signCommitPayload('test', parsedKey) expect(result.raw).to.be.instanceOf(Buffer) }) + + describe('with RSA key', () => { + let rsaKey: ParsedSSHKey + + before(() => { + const {privateKey} = generateKeyPairSync('rsa', {modulusLength: 2048}) + // publicKeyBlob is used for embedding in the envelope only โ€” a placeholder is fine + rsaKey = { + fingerprint: 'SHA256:placeholder', + keyType: 'ssh-rsa', + privateKeyObject: privateKey, + publicKeyBlob: Buffer.alloc(0), + } + }) + + it('signature blob key-type is rsa-sha2-512 (not ssh-rsa)', () => { + const result = signCommitPayload('test payload', rsaKey) + const {keyType} = extractSigFromArmored(result.armored) + expect(keyType).to.equal('rsa-sha2-512') + }) + + it('RSA signature is verifiable with sha512 algorithm', async () => { + const payload = 'tree abc\nauthor Test\n\ncommit message\n' + const result = signCommitPayload(payload, rsaKey) + const {rawSig} = extractSigFromArmored(result.armored) + + // Reconstruct signed data (same structure sshsig-signer builds internally) + function sshString(data: Buffer | string): Buffer { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8') + const lenBuf = Buffer.allocUnsafe(4) + lenBuf.writeUInt32BE(buf.length, 0) + return Buffer.concat([lenBuf, buf]) + } + + const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() + const signedData = Buffer.concat([ + Buffer.from('SSHSIG\0'), + sshString('git'), + sshString(''), + sshString('sha512'), + sshString(messageHash), + ]) + + // Extract public key from our private key and verify + const {createPublicKey} = await import('node:crypto') + const pub = createPublicKey(rsaKey.privateKeyObject) + const valid = cryptoVerify('sha512', signedData, pub, rawSig) + expect(valid).to.be.true + }) + }) }) diff --git a/test/unit/infra/transport/handlers/signing-key-handler.test.ts b/test/unit/infra/transport/handlers/signing-key-handler.test.ts new file mode 100644 index 000000000..02657e8d1 --- /dev/null +++ b/test/unit/infra/transport/handlers/signing-key-handler.test.ts @@ -0,0 +1,175 @@ +/** + * SigningKeyHandler Unit Tests + */ + +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {ITokenStore} from '../../../../../src/server/core/interfaces/auth/i-token-store.js' +import type {ISigningKeyService, SigningKeyResource} from '../../../../../src/server/core/interfaces/services/i-signing-key-service.js' +import type {ITransportServer, RequestHandler} from '../../../../../src/server/core/interfaces/transport/i-transport-server.js' + +import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' +import {NotAuthenticatedError} from '../../../../../src/server/core/domain/errors/task-error.js' +import {SigningKeyHandler} from '../../../../../src/server/infra/transport/handlers/signing-key-handler.js' +import {VcEvents} from '../../../../../src/shared/transport/events/vc-events.js' + +type Stubbed = {[K in keyof T]: SinonStub & T[K]} + +const IAM_BASE_URL = 'https://iam.example.com' + +const FAKE_KEY: SigningKeyResource = { + createdAt: '2024-01-01T00:00:00Z', + fingerprint: 'SHA256:abc123', + id: 'key-id-1', + keyType: 'ssh-ed25519', + publicKey: 'ssh-ed25519 AAAA... test@example.com', + title: 'My laptop', +} + +function makeValidToken(): AuthToken { + return new AuthToken({ + accessToken: 'valid-access-token', + expiresAt: new Date(Date.now() + 3_600_000), + refreshToken: 'refresh', + sessionKey: 'session-key-123', + userEmail: 'test@example.com', + userId: 'user-1', + }) +} + +interface TestDeps { + requestHandler: RequestHandler + signingKeyService: Stubbed + tokenStore: Stubbed + transport: Stubbed +} + +function makeDeps(sandbox: SinonSandbox): TestDeps { + const requestHandlers: Record = {} + + const transport: Stubbed = { + broadcastToProject: sandbox.stub(), + close: sandbox.stub().resolves(), + emitToClient: sandbox.stub(), + emitToProject: sandbox.stub(), + initialize: sandbox.stub().resolves(), + offRequest: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, handler: RequestHandler) => { + requestHandlers[event] = handler + }), + } as unknown as Stubbed + + const tokenStore: Stubbed = { + clear: sandbox.stub().resolves(), + load: sandbox.stub().resolves(makeValidToken()), + save: sandbox.stub().resolves(), + } + + const signingKeyService: Stubbed = { + addKey: sandbox.stub().resolves(FAKE_KEY), + listKeys: sandbox.stub().resolves([FAKE_KEY]), + removeKey: sandbox.stub().resolves(), + } + + return { + requestHandler: requestHandlers[VcEvents.SIGNING_KEY], + signingKeyService, + tokenStore, + transport, + } +} + +function makeHandler(sandbox: SinonSandbox, deps: TestDeps): {getRequestHandler: () => RequestHandler; handler: SigningKeyHandler} { + const requestHandlers: Record = {} + const {transport} = deps + // Re-wire to capture the registered handler + transport.onRequest.callsFake((event: string, h: RequestHandler) => { + requestHandlers[event] = h + }) + + const handler = new SigningKeyHandler({ + iamBaseUrl: IAM_BASE_URL, + tokenStore: deps.tokenStore, + transport, + }) + handler.setup() + + return { + getRequestHandler: () => requestHandlers[VcEvents.SIGNING_KEY], + handler, + } +} + +describe('SigningKeyHandler', () => { + let sandbox: SinonSandbox + + beforeEach(() => { + sandbox = createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('auth guard', () => { + it('throws NotAuthenticatedError when token is missing', async () => { + const deps = makeDeps(sandbox) + deps.tokenStore.load.resolves() + const {getRequestHandler} = makeHandler(sandbox, deps) + + try { + await getRequestHandler()({action: 'list'}, 'client-1') + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(NotAuthenticatedError) + } + }) + + it('throws NotAuthenticatedError when token.isValid() returns false (expired)', async () => { + const deps = makeDeps(sandbox) + const expiredToken = new AuthToken({ + accessToken: 'acc', + expiresAt: new Date(Date.now() - 1000), // expired + refreshToken: 'ref', + sessionKey: 'sess', + userEmail: 'e@e.com', + userId: 'u1', + }) + deps.tokenStore.load.resolves(expiredToken) + const {getRequestHandler} = makeHandler(sandbox, deps) + + try { + await getRequestHandler()({action: 'list'}, 'client-1') + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(NotAuthenticatedError) + } + }) + }) + + describe('list action', () => { + it('returns mapped key list', async () => { + const deps = makeDeps(sandbox) + const {getRequestHandler} = makeHandler(sandbox, deps) + + // The handler creates its own HttpSigningKeyService internally. + // We test the auth check path; for the actual IAM call we rely on http-signing-key-service tests. + // Here we just verify the flow doesn't throw when auth is valid. + // Since we can't easily stub the internal HttpSigningKeyService (ES module instantiation), + // we verify the NotAuthenticatedError path (above) and rely on integration for IAM calls. + // This test verifies setup registers the event handler. + const handler = getRequestHandler() + expect(handler).to.be.a('function') + }) + }) + + describe('setup', () => { + it('registers handler for VcEvents.SIGNING_KEY', () => { + const deps = makeDeps(sandbox) + const {getRequestHandler} = makeHandler(sandbox, deps) + expect(getRequestHandler()).to.be.a('function') + expect(deps.transport.onRequest.calledWith(VcEvents.SIGNING_KEY)).to.be.true + }) + }) +}) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 922c92d40..af8349916 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -745,6 +745,80 @@ describe('VcHandler', () => { } } }) + + it('should NOT sign when signingKey is set but commitSign is absent and sign flag is not passed', async () => { + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({ + files: [{path: 'a.md', staged: true, status: 'added'}], + isClean: false, + }) + // signingKey is configured but commit.sign is NOT set โ€” should NOT auto-sign + deps.vcGitConfigStore.get.resolves({email: 'test@example.com', name: 'Test', signingKey: '/some/key'}) + makeVcHandler(deps).setup() + + await deps.requestHandlers[VcEvents.COMMIT]({message: 'test'}, CLIENT_ID) + + // gitService.commit should have been called without an onSign callback + expect(deps.gitService.commit.calledOnce).to.be.true + const commitArgs = deps.gitService.commit.firstCall.args[0] + expect(commitArgs.onSign).to.be.undefined + }) + + it('should throw SIGNING_KEY_NOT_SUPPORTED for encrypted OpenSSH key (not PASSPHRASE_REQUIRED)', async () => { + // Build a synthetic encrypted OpenSSH key (aes256-ctr cipher) so probeSSHKey detects it + const sshStr = (s: Buffer | string) => { + const b = Buffer.isBuffer(s) ? s : Buffer.from(s) + const len = Buffer.allocUnsafe(4) + len.writeUInt32BE(b.length, 0) + return Buffer.concat([len, b]) + } + + const writeU32 = (v: number) => { + const b = Buffer.allocUnsafe(4) + b.writeUInt32BE(v, 0) + return b + } + + const pubBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(Buffer.alloc(32, 0xaa))]) + const encKeyBuf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), + sshStr('aes256-ctr'), + sshStr('bcrypt'), + sshStr(Buffer.alloc(0)), + writeU32(1), + sshStr(pubBlob), + sshStr(Buffer.alloc(64, 0xbb)), + ]) + const encKeyPem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${encKeyBuf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` + const realTmpDir = fs.mkdtempSync(join(tmpdir(), 'brv-enc-key-test-')) + const encKeyPath = join(realTmpDir, 'enc_key') + writeFileSync(encKeyPath, encKeyPem, {mode: 0o600}) + + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({ + files: [{path: 'a.md', staged: true, status: 'added'}], + isClean: false, + }) + deps.vcGitConfigStore.get.resolves({ + commitSign: true, + email: 'test@example.com', + name: 'Test', + signingKey: encKeyPath, + }) + makeVcHandler(deps).setup() + + try { + await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(VcError) + if (error instanceof VcError) { + expect(error.code).to.equal(VcErrorCode.SIGNING_KEY_NOT_SUPPORTED) + } + } + }) }) describe('handleConfig', () => { @@ -844,7 +918,7 @@ vM/XQYQnZQY1X/sVq1HQAAABF0ZXN0QGV4YW1wbGUuY29tAQI= deps.vcGitConfigStore.get.resolves({}) makeVcHandler(deps).setup() - const result = await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true, key: 'user.signingkey'}, CLIENT_ID) as IVcConfigResponse + const result = await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true}, CLIENT_ID) as IVcConfigResponse expect(deps.vcGitConfigStore.set.calledOnce).to.be.true const savedConfig = deps.vcGitConfigStore.set.firstCall.args[1] @@ -853,6 +927,59 @@ vM/XQYQnZQY1X/sVq1HQAAABF0ZXN0QGV4YW1wbGUuY29tAQI= expect(savedConfig.signingKey).to.equal(keyPath) expect(result.value).to.equal(keyPath) }) + + it('handleImportGitSigning: throws SIGNING_KEY_NOT_FOUND when signingKey path does not exist on disk', async () => { + const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-badpath-')) + mkdirSync(realProjectPath, {recursive: true}) + const {execSync} = await import('node:child_process') + execSync('git init', {cwd: realProjectPath}) + // Point signingKey to a non-existent path (local config overrides global) + execSync('git config user.signingKey "/tmp/definitely-does-not-exist-brv-test"', {cwd: realProjectPath}) + + const deps = makeDeps(sandbox, realProjectPath) + deps.vcGitConfigStore.get.resolves({}) + makeVcHandler(deps).setup() + + try { + await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true}, CLIENT_ID) + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(VcError) + if (error instanceof VcError) { + expect(error.code).to.equal(VcErrorCode.SIGNING_KEY_NOT_FOUND) + } + } + }) + + it('handleImportGitSigning: does NOT set commitSign when gpgsign is explicitly false', async () => { + const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-nosign-')) + mkdirSync(realProjectPath, {recursive: true}) + const {execSync} = await import('node:child_process') + execSync('git init', {cwd: realProjectPath}) + + const keyPath = join(realProjectPath, 'fake_key') + const fakeKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + writeFileSync(keyPath, fakeKey, {mode: 0o600}) + execSync(`git config user.signingkey "${keyPath}"`, {cwd: realProjectPath}) + // Explicitly set gpgsign=false in local repo to override global config + execSync('git config commit.gpgSign false', {cwd: realProjectPath}) + + const deps = makeDeps(sandbox, realProjectPath) + deps.vcGitConfigStore.get.resolves({}) + makeVcHandler(deps).setup() + + await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true}, CLIENT_ID) + + const savedConfig = deps.vcGitConfigStore.set.firstCall.args[1] + // commitSign should be false since gpgsign is explicitly false + expect(savedConfig.commitSign).to.equal(false) + }) }) describe('handlePush', () => { From 48ac6949b79756dd037bc45f42f3cb2f7eda167d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Thu, 16 Apr 2026 20:46:09 +0700 Subject: [PATCH 03/46] fix(vc): address remaining minor observations from PR #435 re-review (ENG-2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead `if (!passphrase)` branch in parseSSHPrivateKey โ€” unreachable since resolveSigningKey short-circuits on opensshEncrypted before calling it - Add optional signingKeyService injection seam to SigningKeyHandlerDeps so action-routing paths (add/list/remove) can be unit tested without stubbing ES module instantiation; auth guard is still enforced when seam is used - Add 4 action-routing tests to signing-key-handler.test.ts (add/list/remove routing + auth guard with injected service) - Add missing handleImportGitSigning test: gpg.format=gpg โ†’ INVALID_CONFIG_VALUE Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../transport/handlers/signing-key-handler.ts | 5 + src/shared/ssh/key-parser.ts | 10 +- .../handlers/signing-key-handler.test.ts | 131 +++++++++++++++++- .../transport/handlers/vc-handler.test.ts | 34 +++++ 4 files changed, 172 insertions(+), 8 deletions(-) diff --git a/src/server/infra/transport/handlers/signing-key-handler.ts b/src/server/infra/transport/handlers/signing-key-handler.ts index 6cdd4f073..7e0156dc7 100644 --- a/src/server/infra/transport/handlers/signing-key-handler.ts +++ b/src/server/infra/transport/handlers/signing-key-handler.ts @@ -14,6 +14,8 @@ import {HttpSigningKeyService} from '../../iam/http-signing-key-service.js' export interface SigningKeyHandlerDeps { iamBaseUrl: string + /** Optional test seam: inject a pre-built service to bypass HTTP client construction. */ + signingKeyService?: ISigningKeyService tokenStore: ITokenStore transport: ITransportServer } @@ -36,11 +38,13 @@ function toSigningKeyItem(resource: SigningKeyResource): SigningKeyItem { */ export class SigningKeyHandler { private readonly iamBaseUrl: string + private readonly injectedService?: ISigningKeyService private readonly tokenStore: ITokenStore private readonly transport: ITransportServer constructor(deps: SigningKeyHandlerDeps) { this.iamBaseUrl = deps.iamBaseUrl + this.injectedService = deps.signingKeyService this.tokenStore = deps.tokenStore this.transport = deps.transport } @@ -58,6 +62,7 @@ export class SigningKeyHandler { private async createService(): Promise { const token = await this.tokenStore.load() if (!token?.isValid()) throw new NotAuthenticatedError() + if (this.injectedService) return this.injectedService const httpClient = new AuthenticatedHttpClient(token.sessionKey) return new HttpSigningKeyService(httpClient, this.iamBaseUrl) } diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 7cba9e4fa..f7d262b36 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -255,13 +255,9 @@ export async function parseSSHPrivateKey( const {cipherName, keyType, privateKeyBlob} = parseOpenSSHKey(raw) if (cipherName !== 'none') { - if (!passphrase) { - throw new Error('Passphrase required for encrypted key') - } - - // Encrypted OpenSSH keys require decryption before parsing. - // For now, throw a clear error โ€” encrypted OpenSSH key support - // requires AES-256-CTR + bcrypt KDF implementation (out of scope for v1 spike). + // Encrypted OpenSSH keys require AES-256-CTR + bcrypt KDF decryption (out of scope for v1). + // resolveSigningKey short-circuits on opensshEncrypted before reaching here, + // so this is a safety net for direct callers. throw new Error( 'Encrypted OpenSSH private keys are not yet supported. ' + 'Please use an unencrypted key or load it via ssh-agent.', diff --git a/test/unit/infra/transport/handlers/signing-key-handler.test.ts b/test/unit/infra/transport/handlers/signing-key-handler.test.ts index 02657e8d1..3f5ec283f 100644 --- a/test/unit/infra/transport/handlers/signing-key-handler.test.ts +++ b/test/unit/infra/transport/handlers/signing-key-handler.test.ts @@ -12,7 +12,7 @@ import type {ITransportServer, RequestHandler} from '../../../../../src/server/c import {AuthToken} from '../../../../../src/server/core/domain/entities/auth-token.js' import {NotAuthenticatedError} from '../../../../../src/server/core/domain/errors/task-error.js' import {SigningKeyHandler} from '../../../../../src/server/infra/transport/handlers/signing-key-handler.js' -import {VcEvents} from '../../../../../src/shared/transport/events/vc-events.js' +import {type SigningKeyItem, VcEvents} from '../../../../../src/shared/transport/events/vc-events.js' type Stubbed = {[K in keyof T]: SinonStub & T[K]} @@ -80,6 +80,50 @@ function makeDeps(sandbox: SinonSandbox): TestDeps { } } +function makeHandlerWithInjectedService(sb: SinonSandbox): { + getRequestHandler: () => RequestHandler + signingKeyService: Stubbed +} { + const requestHandlers: Record = {} + + const signingKeyService: Stubbed = { + addKey: sb.stub().resolves(FAKE_KEY), + listKeys: sb.stub().resolves([FAKE_KEY]), + removeKey: sb.stub().resolves(), + } + + const tokenStore: Stubbed = { + clear: sb.stub().resolves(), + load: sb.stub().resolves(makeValidToken()), + save: sb.stub().resolves(), + } + + const transport: Stubbed = { + broadcastToProject: sb.stub(), + close: sb.stub().resolves(), + emitToClient: sb.stub(), + emitToProject: sb.stub(), + initialize: sb.stub().resolves(), + offRequest: sb.stub(), + onRequest: sb.stub().callsFake((event: string, h: RequestHandler) => { + requestHandlers[event] = h + }), + } as unknown as Stubbed + + const handler = new SigningKeyHandler({ + iamBaseUrl: IAM_BASE_URL, + signingKeyService, + tokenStore, + transport, + }) + handler.setup() + + return { + getRequestHandler: () => requestHandlers[VcEvents.SIGNING_KEY], + signingKeyService, + } +} + function makeHandler(sandbox: SinonSandbox, deps: TestDeps): {getRequestHandler: () => RequestHandler; handler: SigningKeyHandler} { const requestHandlers: Record = {} const {transport} = deps @@ -172,4 +216,89 @@ describe('SigningKeyHandler', () => { expect(deps.transport.onRequest.calledWith(VcEvents.SIGNING_KEY)).to.be.true }) }) + + describe('action routing (via injectable service seam)', () => { + it('add action calls service.addKey and returns mapped key', async () => { + const {getRequestHandler, signingKeyService} = makeHandlerWithInjectedService(sandbox) + + const result = await getRequestHandler()( + {action: 'add', publicKey: 'ssh-ed25519 AAAA... test@example.com', title: 'My laptop'}, + 'client-1', + ) as {action: string; key: SigningKeyItem} + + expect(signingKeyService.addKey.calledOnce).to.be.true + expect(signingKeyService.addKey.calledWith('My laptop', 'ssh-ed25519 AAAA... test@example.com')).to.be.true + expect(result.action).to.equal('add') + expect(result.key.id).to.equal(FAKE_KEY.id) + expect(result.key.fingerprint).to.equal(FAKE_KEY.fingerprint) + }) + + it('list action calls service.listKeys and returns mapped keys', async () => { + const {getRequestHandler, signingKeyService} = makeHandlerWithInjectedService(sandbox) + + const result = await getRequestHandler()( + {action: 'list'}, + 'client-1', + ) as {action: string; keys: SigningKeyItem[]} + + expect(signingKeyService.listKeys.calledOnce).to.be.true + expect(result.action).to.equal('list') + expect(result.keys).to.have.length(1) + expect(result.keys[0].id).to.equal(FAKE_KEY.id) + }) + + it('remove action calls service.removeKey with keyId', async () => { + const {getRequestHandler, signingKeyService} = makeHandlerWithInjectedService(sandbox) + + const result = await getRequestHandler()( + {action: 'remove', keyId: 'key-id-1'}, + 'client-1', + ) as {action: string} + + expect(signingKeyService.removeKey.calledOnce).to.be.true + expect(signingKeyService.removeKey.calledWith('key-id-1')).to.be.true + expect(result.action).to.equal('remove') + }) + + it('still enforces auth guard even when service is injected', async () => { + const requestHandlers: Record = {} + const signingKeyService: Stubbed = { + addKey: sandbox.stub().resolves(FAKE_KEY), + listKeys: sandbox.stub().resolves([FAKE_KEY]), + removeKey: sandbox.stub().resolves(), + } + const tokenStore: Stubbed = { + clear: sandbox.stub().resolves(), + load: sandbox.stub().resolves(), // no token + save: sandbox.stub().resolves(), + } + const transport: Stubbed = { + broadcastToProject: sandbox.stub(), + close: sandbox.stub().resolves(), + emitToClient: sandbox.stub(), + emitToProject: sandbox.stub(), + initialize: sandbox.stub().resolves(), + offRequest: sandbox.stub(), + onRequest: sandbox.stub().callsFake((event: string, h: RequestHandler) => { + requestHandlers[event] = h + }), + } as unknown as Stubbed + + const handler = new SigningKeyHandler({ + iamBaseUrl: IAM_BASE_URL, + signingKeyService, + tokenStore, + transport, + }) + handler.setup() + + try { + await requestHandlers[VcEvents.SIGNING_KEY]({action: 'list'}, 'client-1') + expect.fail('Expected NotAuthenticatedError') + } catch (error) { + expect(error).to.be.instanceOf(NotAuthenticatedError) + expect(signingKeyService.listKeys.called).to.be.false + } + }) + }) }) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index af8349916..38a7b6eeb 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -951,6 +951,40 @@ vM/XQYQnZQY1X/sVq1HQAAABF0ZXN0QGV4YW1wbGUuY29tAQI= } }) + it('handleImportGitSigning: throws INVALID_CONFIG_VALUE when gpg.format is not ssh', async () => { + const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-gpgformat-')) + mkdirSync(realProjectPath, {recursive: true}) + const {execSync} = await import('node:child_process') + execSync('git init', {cwd: realProjectPath}) + + const keyPath = join(realProjectPath, 'fake_key') + const fakeKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + writeFileSync(keyPath, fakeKey, {mode: 0o600}) + execSync(`git config user.signingkey "${keyPath}"`, {cwd: realProjectPath}) + execSync('git config gpg.format gpg', {cwd: realProjectPath}) + + const deps = makeDeps(sandbox, realProjectPath) + deps.vcGitConfigStore.get.resolves({}) + makeVcHandler(deps).setup() + + try { + await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true}, CLIENT_ID) + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(VcError) + if (error instanceof VcError) { + expect(error.code).to.equal(VcErrorCode.INVALID_CONFIG_VALUE) + expect(error.message).to.include('gpg') + } + } + }) + it('handleImportGitSigning: does NOT set commitSign when gpgsign is explicitly false', async () => { const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-nosign-')) mkdirSync(realProjectPath, {recursive: true}) From 65618acd3648294c1205b1f2ba5446da2547e1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Thu, 16 Apr 2026 21:07:42 +0700 Subject: [PATCH 04/46] fix(vc): extract public key from OpenSSH header without decryption (ENG-2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signing-key add and vc config --import-git-signing previously called parseSSHPrivateKey to extract the public key, which fails for encrypted OpenSSH private keys with no .pub sidecar (the most common real-world case). The OpenSSH file format stores the public key in the unencrypted file header โ€” decryption is never needed to derive it. Add extractPublicKey() to shared/ssh/key-parser.ts which: 1. Reads the .pub sidecar if present (preserves the comment field) 2. Falls back to parseOpenSSHKey() for OpenSSH-format files (no decryption) 3. Falls back to parseSSHPrivateKey() for PEM/PKCS8 keys Update add.ts and config.ts to call extractPublicKey() instead of parseSSHPrivateKey(). The .pub workaround still works and the sidecar comment is now used as the default title. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/oclif/commands/signing-key/add.ts | 13 ++- src/oclif/commands/vc/config.ts | 11 +-- src/server/infra/ssh/ssh-key-parser.ts | 1 + src/shared/ssh/index.ts | 1 + src/shared/ssh/key-parser.ts | 44 ++++++++++ test/unit/infra/ssh/ssh-key-parser.test.ts | 93 +++++++++++++++++++--- 6 files changed, 140 insertions(+), 23 deletions(-) diff --git a/src/oclif/commands/signing-key/add.ts b/src/oclif/commands/signing-key/add.ts index 54c379f65..60ecb5c66 100644 --- a/src/oclif/commands/signing-key/add.ts +++ b/src/oclif/commands/signing-key/add.ts @@ -1,7 +1,7 @@ import {Command, Flags} from '@oclif/core' import {readFileSync} from 'node:fs' -import {parseSSHPrivateKey, resolveHome} from '../../../shared/ssh/index.js' +import {extractPublicKey, resolveHome} from '../../../shared/ssh/index.js' import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' @@ -40,12 +40,11 @@ public static flags = { const parts = raw.split(' ') if (!title && parts.length >= 3) title = parts.slice(2).join(' ') } else { - // Private key file โ€” parse to derive public key - const parsed = await parseSSHPrivateKey(keyPath) - // Re-export public key in SSH authorized_keys format: type b64(blob) [comment] - const b64 = parsed.publicKeyBlob.toString('base64') - publicKey = `${parsed.keyType} ${b64}` - if (!title) title = `My ${parsed.keyType} key` + // Private key file โ€” extract public key without decryption (works for encrypted keys too) + const extracted = await extractPublicKey(keyPath) + const b64 = extracted.publicKeyBlob.toString('base64') + publicKey = `${extracted.keyType} ${b64}` + if (!title) title = extracted.comment ?? `My ${extracted.keyType} key` } } catch (error) { this.error( diff --git a/src/oclif/commands/vc/config.ts b/src/oclif/commands/vc/config.ts index 15824c1fa..95e364e85 100644 --- a/src/oclif/commands/vc/config.ts +++ b/src/oclif/commands/vc/config.ts @@ -1,7 +1,7 @@ import {Args, Command, Flags} from '@oclif/core' import {existsSync, readFileSync} from 'node:fs' -import {parseSSHPrivateKey, resolveHome} from '../../../shared/ssh/index.js' +import {extractPublicKey, resolveHome} from '../../../shared/ssh/index.js' import {isVcConfigKey, type IVcConfigResponse, type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' @@ -96,10 +96,11 @@ public static flags = { const parts = raw.split(' ') if (parts.length >= 3) title = parts.slice(2).join(' ') } else { - const parsed = await parseSSHPrivateKey(keyPath) - const b64 = parsed.publicKeyBlob.toString('base64') - publicKey = `${parsed.keyType} ${b64}` - title = `My ${parsed.keyType} key` + // No .pub sidecar โ€” extract public key without decryption (works for encrypted keys) + const extracted = await extractPublicKey(keyPath) + const b64 = extracted.publicKeyBlob.toString('base64') + publicKey = `${extracted.keyType} ${b64}` + title = extracted.comment ?? `My ${extracted.keyType} key` } const response = await withDaemonRetry(async (client) => diff --git a/src/server/infra/ssh/ssh-key-parser.ts b/src/server/infra/ssh/ssh-key-parser.ts index aad221554..86bb94d5f 100644 --- a/src/server/infra/ssh/ssh-key-parser.ts +++ b/src/server/infra/ssh/ssh-key-parser.ts @@ -2,6 +2,7 @@ // Re-export everything so existing server imports (ssh-agent-signer, vc-handler, etc.) continue to work. export { computeFingerprint, + extractPublicKey, getPublicKeyMetadata, parseSSHPrivateKey, probeSSHKey, diff --git a/src/shared/ssh/index.ts b/src/shared/ssh/index.ts index 9a1b2cecd..d47a8a1c1 100644 --- a/src/shared/ssh/index.ts +++ b/src/shared/ssh/index.ts @@ -1,5 +1,6 @@ export { computeFingerprint, + extractPublicKey, getPublicKeyMetadata, parseSSHPrivateKey, probeSSHKey, diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index f7d262b36..175f88d57 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -325,6 +325,50 @@ export function computeFingerprint(publicKeyBlob: Buffer): string { return `SHA256:${hash}` } +/** + * Extract the SSH public key blob and type from a private key file without decryption. + * + * Priority: + * 1. `.pub` sidecar file (authorized_keys format) โ€” also captures the comment field + * 2. OpenSSH native private key format โ€” public key lives in the unencrypted file + * header, so this works even when the private key is encrypted (no passphrase needed) + * 3. PEM/PKCS8 format โ€” requires an unencrypted private key + * + * Throws if the file cannot be read or the format is unrecognised. + */ +export async function extractPublicKey(keyPath: string): Promise<{ + comment?: string + keyType: string + publicKeyBlob: Buffer +}> { + // 1. .pub sidecar + const pubPath = `${keyPath}.pub` + try { + const rawPub = await readFile(pubPath, 'utf8') + const parts = rawPub.trim().split(' ') + if (parts.length >= 2) { + const keyType = parts[0] + const publicKeyBlob = Buffer.from(parts[1], 'base64') + const comment = parts.length >= 3 ? parts.slice(2).join(' ') : undefined + return {comment, keyType, publicKeyBlob} + } + } catch { + // No sidecar โ€” fall through + } + + const raw = await readFile(keyPath, 'utf8') + + // 2. OpenSSH native format: public key is in the unencrypted file header + if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { + const {keyType, publicKeyBlob} = parseOpenSSHKey(raw) + return {keyType, publicKeyBlob} + } + + // 3. PEM/PKCS8 format: requires the private key to be unencrypted + const parsed = await parseSSHPrivateKey(keyPath) + return {keyType: parsed.keyType, publicKeyBlob: parsed.publicKeyBlob} +} + /** * Attempt to extract public key metadata (fingerprint and keyType) from a key path, * checking for a .pub file first, then attempting to parse an OpenSSH private key diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 4f8d681be..4d322f150 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -3,7 +3,7 @@ import {mkdtempSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' -import {parseSSHPrivateKey, probeSSHKey, resolveHome} from '../../../../src/server/infra/ssh/ssh-key-parser.js' +import {extractPublicKey, parseSSHPrivateKey, probeSSHKey, resolveHome} from '../../../../src/server/infra/ssh/ssh-key-parser.js' // โ”€โ”€ Test helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -13,6 +13,25 @@ function writeU32(value: number): Buffer { return buf } +function sshStr(data: Buffer | string): Buffer { + const b = Buffer.isBuffer(data) ? data : Buffer.from(data) + return Buffer.concat([writeU32(b.length), b]) +} + +function makeEncryptedOpenSSHKey(): string { + const pubBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(Buffer.alloc(32, 0xaa))]) + const buf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), + sshStr('aes256-ctr'), + sshStr('bcrypt'), + sshStr(Buffer.alloc(0)), + writeU32(1), + sshStr(pubBlob), + sshStr(Buffer.alloc(64, 0xbb)), + ]) + return `-----BEGIN OPENSSH PRIVATE KEY-----\n${buf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` +} + /** * A real Ed25519 private key in OpenSSH native format (unencrypted). * Generated with: ssh-keygen -t ed25519 -f /tmp/brv_test_key -N "" -C "test@example.com" @@ -52,11 +71,6 @@ describe('probeSSHKey()', () => { }) it('returns opensshEncrypted:true for encrypted OpenSSH-format key', async () => { - const sshStr = (s: Buffer | string) => { - const b = Buffer.isBuffer(s) ? s : Buffer.from(s) - return Buffer.concat([writeU32(b.length), b]) - } - const pubBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(Buffer.alloc(32, 0xaa))]) const buf = Buffer.concat([ Buffer.from('openssh-key-v1\0', 'binary'), @@ -80,11 +94,6 @@ describe('probeSSHKey()', () => { it('returns {exists: true, needsPassphrase: true} for encrypted OpenSSH key', async () => { // Construct a minimal OpenSSH key with cipherName = 'aes256-ctr' to simulate encrypted key. // Must include a valid public key blob + private key blob so parseOpenSSHKey doesn't crash. - const sshStr = (s: Buffer | string) => { - const b = Buffer.isBuffer(s) ? s : Buffer.from(s) - return Buffer.concat([writeU32(b.length), b]) - } - const pubBlob = Buffer.concat([sshStr('ssh-ed25519'), sshStr(Buffer.alloc(32, 0xaa))]) const buf = Buffer.concat([ @@ -167,6 +176,68 @@ describe('parseSSHPrivateKey()', () => { }) }) +// โ”€โ”€ extractPublicKey tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('extractPublicKey()', () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'brv-extract-test-')) + }) + + it('extracts public key from encrypted OpenSSH key with no .pub sidecar', async () => { + const keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, makeEncryptedOpenSSHKey(), {mode: 0o600}) + + const result = await extractPublicKey(keyPath) + + expect(result.keyType).to.equal('ssh-ed25519') + expect(result.publicKeyBlob).to.be.instanceOf(Buffer) + expect(result.publicKeyBlob.length).to.be.greaterThan(0) + expect(result.comment).to.be.undefined + }) + + it('prefers .pub sidecar over OpenSSH header when sidecar exists', async () => { + const keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, makeEncryptedOpenSSHKey(), {mode: 0o600}) + writeFileSync( + `${keyPath}.pub`, + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIERWc7ZeFmViDVndPNPdfAHZi8z9dBhCdlBjVf+xWrUd user@laptop', + {mode: 0o644}, + ) + + const result = await extractPublicKey(keyPath) + + expect(result.keyType).to.equal('ssh-ed25519') + expect(result.comment).to.equal('user@laptop') + // Blob should match what's in the .pub file + const expectedBlob = Buffer.from('AAAAC3NzaC1lZDI1NTE5AAAAIERWc7ZeFmViDVndPNPdfAHZi8z9dBhCdlBjVf+xWrUd', 'base64') + expect(result.publicKeyBlob.equals(expectedBlob)).to.be.true + }) + + it('extracts public key from unencrypted OpenSSH key with no sidecar', async () => { + const keyPath = join(tempDir, 'id_ed25519_unenc') + writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) + + const result = await extractPublicKey(keyPath) + + expect(result.keyType).to.equal('ssh-ed25519') + expect(result.publicKeyBlob).to.be.instanceOf(Buffer) + expect(result.comment).to.be.undefined + }) + + it('throws for a non-existent file', async () => { + let threw = false + try { + await extractPublicKey(join(tempDir, 'no_such_key')) + } catch { + threw = true + } + + expect(threw).to.be.true + }) +}) + // โ”€โ”€ resolveHome tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ describe('resolveHome()', () => { From d4821d52443873d7447557e2709a999e565e61c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Fri, 17 Apr 2026 17:57:31 +0700 Subject: [PATCH 05/46] feat: add support for SSH-agent signing, improved key parsing, and secure passphrase handling for git commits --- docs/ssh-commit-signing.md | 116 ++++++++++++++++++ src/oclif/commands/vc/commit.ts | 18 ++- src/server/infra/git/cogit-url.ts | 2 +- src/server/infra/ssh/ssh-agent-signer.ts | 4 +- .../infra/transport/handlers/vc-handler.ts | 23 ++-- .../infra/vc/file-vc-git-config-store.ts | 2 +- src/shared/ssh/key-parser.ts | 21 +++- src/shared/transport/events/vc-events.ts | 5 +- 8 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 docs/ssh-commit-signing.md diff --git a/docs/ssh-commit-signing.md b/docs/ssh-commit-signing.md new file mode 100644 index 000000000..b7ba9946b --- /dev/null +++ b/docs/ssh-commit-signing.md @@ -0,0 +1,116 @@ +# SSH Commit Signing + +ByteRover hแป— trแปฃ kรฝ commit bแบฑng SSH key. Khi ฤ‘ฦฐแปฃc bแบญt, mแป—i commit sแบฝ ฤ‘ฦฐแปฃc ฤ‘รญnh kรจm chแปฏ kรฝ sแป‘ vร  hiแปƒn thแป‹ trแบกng thรกi **Verified** trรชn ByteRover. + +--- + +## 1. Tแบกo SSH key (nแบฟu chฦฐa cรณ) + +Khuyแบฟn nghแป‹ dรนng Ed25519 โ€” nhแป gแปn vร  bแบฃo mแบญt hฦกn RSA. + +```bash +ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519_signing +``` + +- `-C` lร  comment gแบฏn vร o key (thฦฐแปng lร  email). +- `-f` chแป‰ ฤ‘แป‹nh tรชn file. Bแบกn cรณ thแปƒ dรนng key hiแป‡n cรณ (`~/.ssh/id_ed25519`) nแบฟu ฤ‘รฃ cรณ. + +Lแป‡nh trรชn tแบกo ra 2 file: + +| File | Vai trรฒ | +|---|---| +| `~/.ssh/id_ed25519_signing` | Private key โ€” **giแปฏ bรญ mแบญt** | +| `~/.ssh/id_ed25519_signing.pub` | Public key โ€” ฤ‘ฤƒng kรฝ vร o ByteRover | + +--- + +## 2. ฤฤƒng kรฝ public key lรชn ByteRover + +```bash +brv signing-key add --key ~/.ssh/id_ed25519_signing --title "My laptop" +``` + +- `--key` nhแบญn cแบฃ private key (`.` khรดng cรณ ฤ‘uรดi) hoแบทc public key (`.pub`). +- `--title` lร  nhรฃn ฤ‘แปƒ phรขn biแป‡t cรกc thiแบฟt bแป‹ khรกc nhau (mแบทc ฤ‘แป‹nh lแบฅy comment trong key). + +Kiแปƒm tra key ฤ‘รฃ ฤ‘ฤƒng kรฝ: + +```bash +brv signing-key list +``` + +Kแบฟt quแบฃ trแบฃ vแป `Fingerprint` โ€” dรนng ฤ‘แปƒ ฤ‘แป‘i chiแบฟu khi cแบงn xoรก. + +--- + +## 3. Cแบฅu hรฌnh brv ฤ‘แปƒ dรนng key kรฝ + +Trแป brv ฤ‘แบฟn private key: + +```bash +brv vc config user.signingkey ~/.ssh/id_ed25519_signing +``` + +Bแบญt tแปฑ ฤ‘แป™ng kรฝ tแบฅt cแบฃ commit: + +```bash +brv vc config commit.sign true +``` + +Tแปซ ฤ‘รขy mแป—i `brv vc commit` sแบฝ tแปฑ ฤ‘แป™ng kรฝ, khรดng cแบงn thรชm flag. + +--- + +## 4. Kรฝ thแปง cรดng mแป™t commit (tรนy chแปn) + +Nแบฟu chฦฐa bแบญt `commit.sign`, vแบซn cรณ thแปƒ kรฝ tแปซng commit bแบฑng flag: + +```bash +brv vc commit -m "feat: add feature" --sign +``` + +--- + +## 5. Kiแปƒm tra cแบฅu hรฌnh hiแป‡n tแบกi + +```bash +brv vc config user.signingkey # xem ฤ‘ฦฐแปng dแบซn key ฤ‘ang dรนng +brv vc config commit.sign # xem trแบกng thรกi tแปฑ ฤ‘แป™ng kรฝ +``` + +--- + +## Nแบฟu ฤ‘รฃ cแบฅu hรฌnh SSH signing trong git + +Nแบฟu bแบกn ฤ‘รฃ chแบกy `git config gpg.format ssh` vร  `git config user.signingKey ...`, brv cรณ thแปƒ import trแปฑc tiแบฟp: + +```bash +brv vc config --import-git-signing +``` + +Lแป‡nh nร y ฤ‘แปc `user.signingKey` vร  `commit.gpgSign` tแปซ git config hแป‡ thแป‘ng vร  รกp vร o brv โ€” khรดng cแบงn set thแปง cรดng. + +--- + +## Xoรก key khรดng cรฒn dรนng + +```bash +brv signing-key list # lแบฅy key ID +brv signing-key remove # xoรก +``` + +--- + +## Tรณm tแบฏt luแป“ng thiแบฟt lแบญp + +``` +ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing + โ†“ +brv signing-key add --key ~/.ssh/id_ed25519_signing --title "My laptop" + โ†“ +brv vc config user.signingkey ~/.ssh/id_ed25519_signing + โ†“ +brv vc config commit.sign true + โ†“ +brv vc commit -m "..." โ†’ tแปฑ ฤ‘แป™ng kรฝ โœ… +``` diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index 490c7631d..6655905e4 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -1,4 +1,4 @@ -import {input} from '@inquirer/prompts' +import {password} from '@inquirer/prompts' import {Command, Flags} from '@oclif/core' import {type IVcCommitRequest, type IVcCommitResponse, VcErrorCode, VcEvents} from '../../../shared/transport/events/vc-events.js' @@ -16,6 +16,9 @@ public static flags = { char: 'm', description: 'Commit message', }), + passphrase: Flags.string({ + description: 'SSH key passphrase (prefer BRV_SSH_PASSPHRASE env var)', + }), sign: Flags.boolean({ allowNo: true, description: 'Sign the commit with your configured SSH key. Use --no-sign to override commit.sign=true.', @@ -37,8 +40,9 @@ public static strict = false } const {sign} = flags + const pp = flags.passphrase ?? process.env.BRV_SSH_PASSPHRASE - await this.runCommit(message, sign) + await this.runCommit(message, sign, pp) } private async runCommit(message: string, sign: boolean | undefined, passphrase?: string, attempt: number = 0): Promise { @@ -62,18 +66,20 @@ public static strict = false this.error(`Too many failed passphrase attempts (${VcCommit.MAX_PASSPHRASE_RETRIES}).`) } + if (!process.stdin.isTTY) { + this.error('Passphrase required but no TTY available. Set BRV_SSH_PASSPHRASE env var or use --passphrase flag.') + } + let pp: string try { - pp = await input({ + pp = await password({ message: 'Enter SSH key passphrase:', - // @ts-expect-error โ€” inquirer types vary; hide input for passwords - type: 'password', }) } catch { this.error('Passphrase input cancelled.') } - await this.runCommit(message, sign, pp!, attempt + 1) + await this.runCommit(message, sign, pp, attempt + 1) return } diff --git a/src/server/infra/git/cogit-url.ts b/src/server/infra/git/cogit-url.ts index 306e24518..7008f5803 100644 --- a/src/server/infra/git/cogit-url.ts +++ b/src/server/infra/git/cogit-url.ts @@ -14,7 +14,7 @@ export function buildCogitRemoteUrl(baseUrl: string, teamName: string, spaceName export function parseUserFacingUrl(url: string): null | {spaceName: string; teamName: string} { try { const parsed = new URL(url) - const match = parsed.pathname.match(/\/([^/]+)\/([^/]+?)\.git$/) + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+?)\.git$/) if (!match) return null return {spaceName: match[2], teamName: match[1]} } catch { diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index bd5f8063d..ccabdd2f3 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -176,8 +176,6 @@ export class SshAgentSigner { * Sign a commit payload using the ssh-agent, producing an armored sshsig signature. */ async sign(payload: string): Promise { - const {createHash} = await import('node:crypto') - // Envelope magic: 6 bytes, no null (per PROTOCOL.sshsig blob format) const SSHSIG_MAGIC = Buffer.from('SSHSIG') // Signed data magic: 7 bytes, WITH null (per PROTOCOL.sshsig ยง2 signed data) @@ -244,7 +242,7 @@ export class SshAgentSigner { * - The key at keyPath is not loaded in the agent */ export async function tryGetSshAgentSigner(keyPath: string): Promise { - const agentSocket = process.env.SSH_AUTH_SOCK + const agentSocket = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? String.raw`\\.\pipe\openssh-ssh-agent` : undefined) if (!agentSocket) return null try { diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 1feec47eb..8dba95b5f 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -603,7 +603,7 @@ export class VcHandler { return this.handleImportGitSigning(projectPath) } - const field = FIELD_MAP[data.key] + const field = FIELD_MAP[data.key.toLowerCase()] if (!field) { throw new VcError( `Unknown key '${data.key}'. Allowed: user.name, user.email, user.signingkey, commit.sign.`, @@ -646,13 +646,20 @@ export class VcHandler { // Derive fingerprint for display hint (non-blocking if parse fails) let hint: string | undefined - try { - const parsed = await parseSSHPrivateKey(resolvedPath) - // Cache the parsed key for immediate use - this.signingKeyCache.set(resolvedPath, parsed) - hint = `Fingerprint: ${parsed.fingerprint}` - } catch { - // Encrypted key โ€” require passphrase to get fingerprint; skip hint + if (probe.opensshEncrypted) { + const agent = await tryGetSshAgentSigner(resolvedPath) + if (!agent) { + hint = 'Warning: Key is encrypted OpenSSH format. You must load it into ssh-agent to sign commits.' + } + } else { + try { + const parsed = await parseSSHPrivateKey(resolvedPath) + // Cache the parsed key for immediate use + this.signingKeyCache.set(resolvedPath, parsed) + hint = `Fingerprint: ${parsed.fingerprint}` + } catch { + // Encrypted key โ€” require passphrase to get fingerprint; skip hint + } } await this.vcGitConfigStore.set(projectPath, {...existing, signingKey: resolvedPath}) diff --git a/src/server/infra/vc/file-vc-git-config-store.ts b/src/server/infra/vc/file-vc-git-config-store.ts index e4669ea59..6fba36116 100644 --- a/src/server/infra/vc/file-vc-git-config-store.ts +++ b/src/server/infra/vc/file-vc-git-config-store.ts @@ -51,7 +51,7 @@ export class FileVcGitConfigStore implements IVcGitConfigStore { public async set(projectPath: string, config: IVcGitConfig): Promise { const projectDir = join(this.deps.getDataDir(), 'projects', projectKey(projectPath)) await mkdir(projectDir, {recursive: true}) - await writeFile(join(projectDir, 'vc-git-config.json'), JSON.stringify(config, null, 2), 'utf8') + await writeFile(join(projectDir, 'vc-git-config.json'), JSON.stringify(config, null, 2), {encoding: 'utf8', mode: 0o600}) } private configPath(projectPath: string): string { diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 175f88d57..df4465581 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -2,6 +2,7 @@ import {createHash, createPrivateKey, createPublicKey} from 'node:crypto' import {constants} from 'node:fs' import {access, readFile} from 'node:fs/promises' import {homedir} from 'node:os' +import path from 'node:path' import type {ParsedSSHKey, SSHKeyProbe, SSHKeyType} from './types.js' @@ -190,11 +191,11 @@ function isPassphraseError(err: unknown): boolean { const code = 'code' in err && typeof (err as {code: unknown}).code === 'string' ? (err as {code: string}).code : '' - if (code.includes('ERR_OSSL') && code.includes('DECRYPT')) return true + if (code.startsWith('ERR_OSSL')) return true // Fallback: string matching for compatibility across Node.js/OpenSSL versions const msg = err.message.toLowerCase() - return msg.includes('bad decrypt') || msg.includes('passphrase') || msg.includes('bad password') + return /bad decrypt|passphrase|bad password|interrupted or cancelled|unsupported/.test(msg) } // โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -216,6 +217,11 @@ export async function probeSSHKey(keyPath: string): Promise { if (raw.includes('BEGIN OPENSSH PRIVATE KEY')) { // OpenSSH format: check cipherName field const parsed = parseOpenSSHKey(raw) + + if (parsed.keyType !== 'ssh-ed25519') { + throw new Error(`Unsupported OpenSSH key type: ${parsed.keyType}. Only ssh-ed25519 is supported natively. Please load this key into ssh-agent instead.`) + } + const needsPassphrase = parsed.cipherName !== 'none' return { exists: true, @@ -225,7 +231,11 @@ export async function probeSSHKey(keyPath: string): Promise { } // PEM/PKCS8 format (RSA, ECDSA with traditional headers) - createPrivateKey({format: 'pem', key: raw}) + const pk = createPrivateKey({format: 'pem', key: raw}) + if (pk.asymmetricKeyType !== 'ed25519') { + throw new Error(`Unsupported PEM key type: ${pk.asymmetricKeyType}. Only ed25519 is supported natively. Please load this key into ssh-agent instead.`) + } + return {exists: true, needsPassphrase: false} } catch (error: unknown) { if (isPassphraseError(error)) { @@ -408,8 +418,9 @@ export async function getPublicKeyMetadata(keyPath: string): Promise Date: Mon, 20 Apr 2026 17:36:07 +0700 Subject: [PATCH 06/46] fix(ssh): use 6-byte SSHSIG preamble for signed data per PROTOCOL.sshsig PROTOCOL.sshsig defines MAGIC_PREAMBLE as byte[6] "SSHSIG" for both the envelope and the signed-data structure. The previous 'SSHSIG\0' (7 bytes, with null terminator) made every signature reject under `git verify-commit` and CoGit's verifier. Add a round-trip test against `ssh-keygen -Y check-novalidate` so this class of spec-misreading bug cannot recur silently. Also fix two pre-existing self-asserting tests that re-encoded the wrong preamble. Fixes ENG-2002 AC4 (and unblocks AC5). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/ssh-agent-signer.ts | 6 +- src/server/infra/ssh/sshsig-signer.ts | 11 +-- test/unit/infra/ssh/ssh-agent-signer.test.ts | 16 +++-- test/unit/infra/ssh/sshsig-signer.test.ts | 73 +++++++++++++++++++- 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index ccabdd2f3..e7d77cf2e 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -176,10 +176,10 @@ export class SshAgentSigner { * Sign a commit payload using the ssh-agent, producing an armored sshsig signature. */ async sign(payload: string): Promise { - // Envelope magic: 6 bytes, no null (per PROTOCOL.sshsig blob format) + // PROTOCOL.sshsig defines MAGIC_PREAMBLE as `byte[6] "SSHSIG"` for both the + // envelope and the signed-data structure. See sshsig-signer.ts for the rationale. const SSHSIG_MAGIC = Buffer.from('SSHSIG') - // Signed data magic: 7 bytes, WITH null (per PROTOCOL.sshsig ยง2 signed data) - const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG\0') + const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG') const NAMESPACE = 'git' const HASH_ALGORITHM = 'sha512' diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts index 346a38e15..c6abeb035 100644 --- a/src/server/infra/ssh/sshsig-signer.ts +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -3,10 +3,13 @@ import {createHash, sign} from 'node:crypto' import type {ParsedSSHKey, SSHSignatureResult} from './types.js' // sshsig constants -// Envelope magic: 6 bytes, no null terminator (per PROTOCOL.sshsig blob format) +// PROTOCOL.sshsig defines MAGIC_PREAMBLE as `byte[6] "SSHSIG"` for both the +// envelope and the signed-data structure. The OpenSSH C source uses +// `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes (no null terminator). +// Adding the null byte produces signatures that fail `ssh-keygen -Y verify` +// and `git verify-commit`. const SSHSIG_MAGIC = Buffer.from('SSHSIG') -// Signed data magic: 7 bytes, WITH null terminator (per PROTOCOL.sshsig ยง2 signed data) -const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG\0') +const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG') const SSHSIG_VERSION = 1 const NAMESPACE = 'git' const HASH_ALGORITHM = 'sha512' @@ -39,7 +42,7 @@ export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignat // 2. Build the "signed data" structure per PROTOCOL.sshsig ยง2 // This is what the private key actually signs โ€” NOT the raw payload. const signedData = Buffer.concat([ - SSHSIG_SIGNED_DATA_MAGIC, // "SSHSIG\0" (7 bytes with null terminator) + SSHSIG_SIGNED_DATA_MAGIC, // "SSHSIG" (6 bytes, byte[6] per spec) sshString(NAMESPACE), // "git" sshString(''), // reserved (empty) sshString(HASH_ALGORITHM), // "sha512" diff --git a/test/unit/infra/ssh/ssh-agent-signer.test.ts b/test/unit/infra/ssh/ssh-agent-signer.test.ts index 0f6bc1b63..05e7afe18 100644 --- a/test/unit/infra/ssh/ssh-agent-signer.test.ts +++ b/test/unit/infra/ssh/ssh-agent-signer.test.ts @@ -247,7 +247,7 @@ describe('SshAgentSigner.sign()', () => { } }) - it('signed data sent to agent uses 7-byte magic WITH null terminator', async () => { + it('signed data sent to agent starts with 6-byte SSHSIG magic followed by namespace', async () => { const parsed = await parseSSHPrivateKey(keyPath) // We'll capture the signed data sent to the agent's sign method @@ -305,10 +305,18 @@ describe('SshAgentSigner.sign()', () => { expect(signer).to.not.be.null await signer!.sign('test payload for magic check') - // Verify the signed data starts with SSHSIG\0 (7 bytes) + // Per PROTOCOL.sshsig the signed-data structure is: + // byte[6] MAGIC_PREAMBLE ("SSHSIG" โ€” NO null terminator) + // string namespace + // ... + // Asserting only the first 7 bytes is unsafe because the namespace length + // prefix happens to begin with 0x00 (uint32 BE of "git" = 0x00000003), so a + // wrong 7-byte 'SSHSIG\0' check would pass spuriously. expect(capturedSignData).to.not.be.undefined - const signedMagic = capturedSignData!.subarray(0, 7).toString('binary') - expect(signedMagic).to.equal('SSHSIG\0') + expect(capturedSignData!.subarray(0, 6).toString()).to.equal('SSHSIG') + const namespaceLen = capturedSignData!.readUInt32BE(6) + expect(namespaceLen).to.equal(3) + expect(capturedSignData!.subarray(10, 13).toString()).to.equal('git') } finally { server.close() } diff --git a/test/unit/infra/ssh/sshsig-signer.test.ts b/test/unit/infra/ssh/sshsig-signer.test.ts index 6aeb88da8..ed40eeabf 100644 --- a/test/unit/infra/ssh/sshsig-signer.test.ts +++ b/test/unit/infra/ssh/sshsig-signer.test.ts @@ -1,6 +1,7 @@ import {expect} from 'chai' +import {execFileSync} from 'node:child_process' import {createHash, verify as cryptoVerify, generateKeyPairSync} from 'node:crypto' -import {mkdtempSync, writeFileSync} from 'node:fs' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -9,6 +10,27 @@ import type {ParsedSSHKey} from '../../../../src/server/infra/ssh/types.js' import {parseSSHPrivateKey} from '../../../../src/server/infra/ssh/ssh-key-parser.js' import {signCommitPayload} from '../../../../src/server/infra/ssh/sshsig-signer.js' +/** + * Returns true iff `ssh-keygen` is on PATH and supports the `-Y` subcommand family. + * + * Probe: `ssh-keygen -Y check-novalidate` with no further args. The binary exits + * non-zero with "Too few arguments..." but only when both the binary AND the -Y + * subcommand family are available. ENOENT (binary missing) surfaces as `code === 'ENOENT'`. + */ +function isSshKeygenAvailable(): boolean { + try { + execFileSync('ssh-keygen', ['-Y', 'check-novalidate'], {stdio: 'ignore'}) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && (error as {code: string}).code === 'ENOENT') { + return false + } + + // Non-zero exit (expected: missing namespace) โ€” binary is present and supports -Y. + return true + } +} + /** Parse an SSH wire-format length-prefixed string from a buffer. Returns [value, nextOffset]. */ function readSSHString(buf: Buffer, offset: number): [Buffer, number] { const len = buf.readUInt32BE(offset) @@ -145,7 +167,8 @@ describe('signCommitPayload()', () => { const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() const signedData = Buffer.concat([ - Buffer.from('SSHSIG\0'), + // 6-byte preamble per PROTOCOL.sshsig (no null terminator). + Buffer.from('SSHSIG'), sshString('git'), sshString(''), sshString('sha512'), @@ -159,4 +182,50 @@ describe('signCommitPayload()', () => { expect(valid).to.be.true }) }) + + // Round-trip suite: feed our armored signature back to the OpenSSH reference + // verifier (`ssh-keygen -Y check-novalidate`) and assert acceptance. This is the + // only test in the file that validates against an external verifier โ€” every other + // assertion is structural and can pass while the signature is cryptographically + // invalid (which is exactly how B0/ENG-2002 slipped past the original reviews). + describe('round-trip with ssh-keygen', () => { + let roundtripDir: string + let roundtripKeyPath: string + let roundtripKey: Awaited> + + before(async function () { + if (!isSshKeygenAvailable()) { + console.warn('[sshsig round-trip] ssh-keygen not on PATH โ€” skipping suite') + this.skip() + } + + roundtripDir = mkdtempSync(join(tmpdir(), 'brv-sshsig-roundtrip-')) + roundtripKeyPath = join(roundtripDir, 'id_ed25519') + execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'roundtrip', '-f', roundtripKeyPath]) + roundtripKey = await parseSSHPrivateKey(roundtripKeyPath) + }) + + after(() => { + if (roundtripDir) rmSync(roundtripDir, {force: true, recursive: true}) + }) + + it('ssh-keygen -Y check-novalidate accepts the signature', () => { + const payload = 'tree abc123\nparent def456\nauthor Test 0 +0000\n\nB0 round-trip\n' + const {armored} = signCommitPayload(payload, roundtripKey) + + const sigPath = join(roundtripDir, 'commit.sig') + writeFileSync(sigPath, armored) + + // ssh-keygen reads message from stdin, signature from -s file. + // -n git: namespace must match what signCommitPayload uses. + // check-novalidate: verifies cryptographic validity without an allowed_signers file. + const stdout = execFileSync( + 'ssh-keygen', + ['-Y', 'check-novalidate', '-n', 'git', '-s', sigPath], + {input: payload, stdio: ['pipe', 'pipe', 'pipe']}, + ).toString() + + expect(stdout).to.match(/Good "git" signature/i) + }) + }) }) From fb1c71b379f2329c25063d5b12e5e2d4c96fc3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 17:42:56 +0700 Subject: [PATCH 07/46] fix(ssh): drop 'unsupported' from passphrase-error regex (ENG-2002 B6) isPassphraseError() previously matched /unsupported/, which collided with parseOpenSSHKey's own "Unsupported OpenSSH key type: ..." error string. probeSSHKey then reported needsPassphrase:true for any non-Ed25519 OpenSSH key, triggering a spurious passphrase prompt instead of surfacing the real "unsupported key type" error. Add regression tests for RSA and ECDSA OpenSSH-format keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/key-parser.ts | 11 ++++- test/unit/infra/ssh/ssh-key-parser.test.ts | 57 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index df4465581..831167302 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -193,9 +193,16 @@ function isPassphraseError(err: unknown): boolean { : '' if (code.startsWith('ERR_OSSL')) return true - // Fallback: string matching for compatibility across Node.js/OpenSSL versions + // Fallback: string matching for compatibility across Node.js/OpenSSL versions. + // + // NOTE: Do NOT add `/unsupported/` here. Several callers (parseOpenSSHKey, + // parseSSHPrivateKey) throw `"Unsupported OpenSSH key type: ..."` / + // `"Unsupported PEM key type: ..."` for keys this build cannot parse natively. + // Including `/unsupported/` would false-match those errors and cause + // probeSSHKey to incorrectly report the key needs a passphrase, producing a + // spurious passphrase prompt instead of surfacing the real error. const msg = err.message.toLowerCase() - return /bad decrypt|passphrase|bad password|interrupted or cancelled|unsupported/.test(msg) + return /bad decrypt|passphrase|bad password|interrupted or cancelled/.test(msg) } // โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 4d322f150..7fea47dbd 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -91,6 +91,63 @@ describe('probeSSHKey()', () => { expect(result.opensshEncrypted).to.be.true }) + it('throws "Unsupported OpenSSH key type" for unencrypted RSA OpenSSH key (does not false-prompt for passphrase)', async () => { + // Regression test for ENG-2002 B6: the isPassphraseError regex used to include + // /unsupported/, which false-matched parseOpenSSHKey's own error string and + // caused probeSSHKey to incorrectly return needsPassphrase:true for RSA/ECDSA + // OpenSSH-format keys, triggering a spurious passphrase prompt. + const pubBlob = Buffer.concat([sshStr('ssh-rsa'), sshStr(Buffer.alloc(64, 0xaa))]) + const buf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), + sshStr('none'), // cipher: NOT encrypted + sshStr('none'), // kdf + sshStr(Buffer.alloc(0)), + writeU32(1), + sshStr(pubBlob), + sshStr(Buffer.alloc(64, 0xbb)), + ]) + const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${buf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` + const keyPath = join(tempDir, 'id_rsa_openssh') + writeFileSync(keyPath, pem, {mode: 0o600}) + + let caught: unknown + try { + await probeSSHKey(keyPath) + } catch (error) { + caught = error + } + + expect(caught, 'probeSSHKey must throw for unsupported key type, not return needsPassphrase:true').to.be.instanceOf(Error) + expect((caught as Error).message).to.match(/Unsupported OpenSSH key type/) + }) + + it('throws "Unsupported OpenSSH key type" for unencrypted ECDSA OpenSSH key (does not false-prompt for passphrase)', async () => { + // Same regression as above, but exercising ecdsa-sha2-nistp256. + const pubBlob = Buffer.concat([sshStr('ecdsa-sha2-nistp256'), sshStr(Buffer.alloc(65, 0xaa))]) + const buf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), + sshStr('none'), + sshStr('none'), + sshStr(Buffer.alloc(0)), + writeU32(1), + sshStr(pubBlob), + sshStr(Buffer.alloc(64, 0xbb)), + ]) + const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${buf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` + const keyPath = join(tempDir, 'id_ecdsa_openssh') + writeFileSync(keyPath, pem, {mode: 0o600}) + + let caught: unknown + try { + await probeSSHKey(keyPath) + } catch (error) { + caught = error + } + + expect(caught).to.be.instanceOf(Error) + expect((caught as Error).message).to.match(/Unsupported OpenSSH key type/) + }) + it('returns {exists: true, needsPassphrase: true} for encrypted OpenSSH key', async () => { // Construct a minimal OpenSSH key with cipherName = 'aes256-ctr' to simulate encrypted key. // Must include a valid public key blob + private key blob so parseOpenSSHKey doesn't crash. From d7ceeece2d529c5fba55d03fceaf7640695e1147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 17:46:04 +0700 Subject: [PATCH 08/46] test(ssh): add regression test confirming B6 closes ENG-2002 B7 B7 reported that RSA/ECDSA OpenSSH keys triggered repeated passphrase prompts (RSA: 2, ECDSA: 3) and theorised an "implicit PASSPHRASE_REQUIRED conversion path" elsewhere in the transport layer. Tracing the code shows the only PASSPHRASE_REQUIRED throw is in resolveSigningKey, gated on probe.needsPassphrase. With B6's regex fix in place probeSSHKey now throws "Unsupported OpenSSH key type" directly for non-Ed25519 OpenSSH keys, so PASSPHRASE_REQUIRED is never produced and the CLI retry loop never starts. This integration test exercises handleCommit end-to-end with a synthetic ssh-rsa OpenSSH key and asserts the propagated error is the unsupported keytype error, NOT PASSPHRASE_REQUIRED. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../transport/handlers/vc-handler.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 38a7b6eeb..8b0007c4e 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -819,6 +819,71 @@ describe('VcHandler', () => { } } }) + + it('should propagate "Unsupported OpenSSH key type" for RSA OpenSSH key, NOT throw PASSPHRASE_REQUIRED (ENG-2002 B7)', async () => { + // Regression test for ENG-2002 B7: before B6's regex fix, probeSSHKey + // false-marked RSA/ECDSA OpenSSH keys as needsPassphrase:true and + // handleCommit threw PASSPHRASE_REQUIRED, triggering a spurious passphrase + // prompt in the CLI. After B6, the unsupported-key-type error must + // propagate cleanly โ€” no PASSPHRASE_REQUIRED conversion anywhere. + const sshStr = (s: Buffer | string) => { + const b = Buffer.isBuffer(s) ? s : Buffer.from(s) + const len = Buffer.allocUnsafe(4) + len.writeUInt32BE(b.length, 0) + return Buffer.concat([len, b]) + } + + const writeU32 = (v: number) => { + const b = Buffer.allocUnsafe(4) + b.writeUInt32BE(v, 0) + return b + } + + // Unencrypted ssh-rsa OpenSSH key (cipher='none') so the only failure mode + // is the unsupported-keytype check, not encryption handling. + const pubBlob = Buffer.concat([sshStr('ssh-rsa'), sshStr(Buffer.alloc(64, 0xaa))]) + const rsaKeyBuf = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'binary'), + sshStr('none'), + sshStr('none'), + sshStr(Buffer.alloc(0)), + writeU32(1), + sshStr(pubBlob), + sshStr(Buffer.alloc(64, 0xbb)), + ]) + const rsaKeyPem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${rsaKeyBuf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` + const realTmpDir = fs.mkdtempSync(join(tmpdir(), 'brv-rsa-key-test-')) + const rsaKeyPath = join(realTmpDir, 'rsa_key') + writeFileSync(rsaKeyPath, rsaKeyPem, {mode: 0o600}) + + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({ + files: [{path: 'a.md', staged: true, status: 'added'}], + isClean: false, + }) + deps.vcGitConfigStore.get.resolves({ + commitSign: true, + email: 'test@example.com', + name: 'Test', + signingKey: rsaKeyPath, + }) + makeVcHandler(deps).setup() + + try { + await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(Error) + // The error must NOT be PASSPHRASE_REQUIRED โ€” that would re-trigger the + // CLI retry loop and produce spurious passphrase prompts. + if (error instanceof VcError) { + expect(error.code).to.not.equal(VcErrorCode.PASSPHRASE_REQUIRED) + } + + expect((error as Error).message).to.match(/Unsupported OpenSSH key type/) + } + }) }) describe('handleConfig', () => { From 77464d7f45d1fffc086be4d645e4611aac286b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 17:58:57 +0700 Subject: [PATCH 09/46] fix(oclif): remove interactive passphrase prompt from vc commit (ENG-2002 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Bao's review (2026-04-17), oclif commands run non-interactively and must not collect user input via inquirer prompts โ€” passphrase entry belongs in the TUI's Ink layer per ticket ยงSigning Flow step 2. Replace the @inquirer/prompts password() + retry-recursion logic with a clear actionable error directing users to --passphrase or BRV_SSH_PASSPHRASE. Add two examples covering both invocation styles (addresses B1-examples). The Ink-layer passphrase component for the TUI remains an open follow-up (ticket ยงSigning Flow step 2) โ€” out of scope for this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/oclif/commands/vc/commit.ts | 51 ++++++++++++--------------------- 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index 6655905e4..d346a4113 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -1,4 +1,3 @@ -import {password} from '@inquirer/prompts' import {Command, Flags} from '@oclif/core' import {type IVcCommitRequest, type IVcCommitResponse, VcErrorCode, VcEvents} from '../../../shared/transport/events/vc-events.js' @@ -10,22 +9,23 @@ export default class VcCommit extends Command { '<%= config.bin %> <%= command.id %> -m "Add project architecture notes"', '<%= config.bin %> <%= command.id %> -m "Signed commit" --sign', '<%= config.bin %> <%= command.id %> -m "Unsigned commit" --no-sign', + '<%= config.bin %> <%= command.id %> -m "Signed (encrypted key)" --sign --passphrase "$MY_PASS"', + 'BRV_SSH_PASSPHRASE="$MY_PASS" <%= config.bin %> <%= command.id %> -m "Signed (env)" --sign', ] -public static flags = { + public static flags = { message: Flags.string({ char: 'm', description: 'Commit message', }), passphrase: Flags.string({ - description: 'SSH key passphrase (prefer BRV_SSH_PASSPHRASE env var)', + description: 'SSH key passphrase (or set BRV_SSH_PASSPHRASE env var)', }), sign: Flags.boolean({ allowNo: true, description: 'Sign the commit with your configured SSH key. Use --no-sign to override commit.sign=true.', }), } -private static readonly MAX_PASSPHRASE_RETRIES = 3 -public static strict = false + public static strict = false public async run(): Promise { const {argv, flags} = await this.parse(VcCommit) @@ -40,13 +40,12 @@ public static strict = false } const {sign} = flags - const pp = flags.passphrase ?? process.env.BRV_SSH_PASSPHRASE - - await this.runCommit(message, sign, pp) - } - - private async runCommit(message: string, sign: boolean | undefined, passphrase?: string, attempt: number = 0): Promise { - const payload: IVcCommitRequest = {message, ...(sign === undefined ? {} : {sign}), ...(passphrase ? {passphrase} : {})} + const passphrase = flags.passphrase ?? process.env.BRV_SSH_PASSPHRASE + const payload: IVcCommitRequest = { + message, + ...(sign === undefined ? {} : {sign}), + ...(passphrase ? {passphrase} : {}), + } try { const result = await withDaemonRetry(async (client) => @@ -56,31 +55,19 @@ public static strict = false const sigIndicator = result.signed ? ' ๐Ÿ”' : '' this.log(`[${result.sha.slice(0, 7)}] ${result.message}${sigIndicator}`) } catch (error) { - // Passphrase required โ€” prompt and retry (capped) + // oclif commands run non-interactively (no TUI). When the signing key is + // passphrase-protected and the user did not provide one, surface a clear + // actionable error instead of prompting โ€” passphrase entry belongs in the + // TUI's Ink layer (ENG-2002 ยงSigning Flow step 2). if ( error instanceof Error && 'code' in error && (error as {code: string}).code === VcErrorCode.PASSPHRASE_REQUIRED ) { - if (attempt >= VcCommit.MAX_PASSPHRASE_RETRIES) { - this.error(`Too many failed passphrase attempts (${VcCommit.MAX_PASSPHRASE_RETRIES}).`) - } - - if (!process.stdin.isTTY) { - this.error('Passphrase required but no TTY available. Set BRV_SSH_PASSPHRASE env var or use --passphrase flag.') - } - - let pp: string - try { - pp = await password({ - message: 'Enter SSH key passphrase:', - }) - } catch { - this.error('Passphrase input cancelled.') - } - - await this.runCommit(message, sign, pp, attempt + 1) - return + this.error( + 'Signing key requires a passphrase. Provide it via the --passphrase flag ' + + 'or the BRV_SSH_PASSPHRASE environment variable, then retry.', + ) } this.error(formatConnectionError(error)) From 608aa9f5979abd33d153df036ebc3a8ce350f01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 18:21:46 +0700 Subject: [PATCH 10/46] docs(ssh): rewrite SSH signing guide in English with full coverage (ENG-2002 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bao's review (2026-04-17) noted the prior doc was Vietnamese-only and missing several critical pieces. This rewrite addresses all four gaps: - English (replaces VN-only โ€” accessible to the full team) - Cross-platform notes for macOS, Linux, Windows native, and WSL - `--passphrase` flag and `BRV_SSH_PASSPHRASE` env var documented - Encrypted-OpenSSH and RSA/ECDSA caveats with the ssh-add โ†’ ssh-agent recovery path Also adds a supported-key-formats matrix that makes the narrowed v1 scope explicit (Ed25519-unencrypted-file native; everything else via ssh-agent), plus a troubleshooting table covering the four user-facing error strings in the current implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ssh-commit-signing.md | 155 +++++++++++++++++++++++-------------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/docs/ssh-commit-signing.md b/docs/ssh-commit-signing.md index b7ba9946b..98ad7f608 100644 --- a/docs/ssh-commit-signing.md +++ b/docs/ssh-commit-signing.md @@ -1,116 +1,153 @@ # SSH Commit Signing -ByteRover hแป— trแปฃ kรฝ commit bแบฑng SSH key. Khi ฤ‘ฦฐแปฃc bแบญt, mแป—i commit sแบฝ ฤ‘ฦฐแปฃc ฤ‘รญnh kรจm chแปฏ kรฝ sแป‘ vร  hiแปƒn thแป‹ trแบกng thรกi **Verified** trรชn ByteRover. +ByteRover signs commits with an SSH key. When enabled, every commit carries a +cryptographic signature and shows as **Verified** in the ByteRover UI and via +`git verify-commit`. ---- - -## 1. Tแบกo SSH key (nแบฟu chฦฐa cรณ) - -Khuyแบฟn nghแป‹ dรนng Ed25519 โ€” nhแป gแปn vร  bแบฃo mแบญt hฦกn RSA. +## Quick start ```bash +# 1. Generate (skip if you already have a key) ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519_signing -``` -- `-C` lร  comment gแบฏn vร o key (thฦฐแปng lร  email). -- `-f` chแป‰ ฤ‘แป‹nh tรชn file. Bแบกn cรณ thแปƒ dรนng key hiแป‡n cรณ (`~/.ssh/id_ed25519`) nแบฟu ฤ‘รฃ cรณ. +# 2. Register the public key with ByteRover +brv signing-key add --key ~/.ssh/id_ed25519_signing.pub --title "My laptop" -Lแป‡nh trรชn tแบกo ra 2 file: +# 3. Tell brv where the private key lives +brv vc config user.signingkey ~/.ssh/id_ed25519_signing -| File | Vai trรฒ | -|---|---| -| `~/.ssh/id_ed25519_signing` | Private key โ€” **giแปฏ bรญ mแบญt** | -| `~/.ssh/id_ed25519_signing.pub` | Public key โ€” ฤ‘ฤƒng kรฝ vร o ByteRover | +# 4. Sign every commit automatically +brv vc config commit.sign true +``` + +From here `brv vc commit -m "..."` produces a signed commit. --- -## 2. ฤฤƒng kรฝ public key lรชn ByteRover +## Supported key formats + +| Key format | Direct (file) | Via ssh-agent | +| --------------------------------------------------- | :-----------: | :-----------: | +| Ed25519, OpenSSH format, **unencrypted** | โœ… | โœ… | +| Ed25519, OpenSSH format, passphrase-protected | โŒ | โœ… | +| RSA / ECDSA, any format | โŒ | โœ… | + +If your key falls into a row that only supports the ssh-agent column, load it +into the agent before signing: ```bash -brv signing-key add --key ~/.ssh/id_ed25519_signing --title "My laptop" +ssh-add ~/.ssh/id_rsa # macOS / Linux / WSL ``` -- `--key` nhแบญn cแบฃ private key (`.` khรดng cรณ ฤ‘uรดi) hoแบทc public key (`.pub`). -- `--title` lร  nhรฃn ฤ‘แปƒ phรขn biแป‡t cรกc thiแบฟt bแป‹ khรกc nhau (mแบทc ฤ‘แป‹nh lแบฅy comment trong key). - -Kiแปƒm tra key ฤ‘รฃ ฤ‘ฤƒng kรฝ: +On Windows PowerShell with the OpenSSH agent service running: -```bash -brv signing-key list +```powershell +Get-Service ssh-agent | Set-Service -StartupType Automatic +Start-Service ssh-agent +ssh-add $HOME\.ssh\id_rsa ``` -Kแบฟt quแบฃ trแบฃ vแป `Fingerprint` โ€” dรนng ฤ‘แปƒ ฤ‘แป‘i chiแบฟu khi cแบงn xoรก. +`brv vc commit --sign` automatically prefers ssh-agent when it is available +(`SSH_AUTH_SOCK` set on Unix, `\\.\pipe\openssh-ssh-agent` on Windows) โ€” you +do not need to change any brv config. --- -## 3. Cแบฅu hรฌnh brv ฤ‘แปƒ dรนng key kรฝ +## Passphrase-protected keys (without ssh-agent) -Trแป brv ฤ‘แบฟn private key: +For Ed25519 keys whose private file lives unencrypted on disk you do not need a +passphrase. For any other passphrase-protected key, **use ssh-agent** (above). +Only Ed25519-via-PEM-PKCS8 supports passphrase entry directly: ```bash -brv vc config user.signingkey ~/.ssh/id_ed25519_signing -``` +# Pass once via flag +brv vc commit -m "msg" --sign --passphrase "$MY_PASS" -Bแบญt tแปฑ ฤ‘แป™ng kรฝ tแบฅt cแบฃ commit: - -```bash -brv vc config commit.sign true +# Or via env var (preferred for CI / scripts โ€” keeps the secret out of shell history) +BRV_SSH_PASSPHRASE="$MY_PASS" brv vc commit -m "msg" --sign ``` -Tแปซ ฤ‘รขy mแป—i `brv vc commit` sแบฝ tแปฑ ฤ‘แป™ng kรฝ, khรดng cแบงn thรชm flag. +`brv` does **not** prompt interactively for the passphrase โ€” `brv vc` is a +non-interactive oclif command. If a passphrase is required and neither +`--passphrase` nor `BRV_SSH_PASSPHRASE` is provided, the command exits with a +clear error pointing to both options. --- -## 4. Kรฝ thแปง cรดng mแป™t commit (tรนy chแปn) +## Cross-platform notes -Nแบฟu chฦฐa bแบญt `commit.sign`, vแบซn cรณ thแปƒ kรฝ tแปซng commit bแบฑng flag: +### macOS / Linux -```bash -brv vc commit -m "feat: add feature" --sign -``` +Default key location: `~/.ssh/id_ed25519`. ssh-agent is started by your shell +or DE; check with `ssh-add -l`. + +### Windows (PowerShell, native OpenSSH) + +- Install OpenSSH from "Optional Features" if not present. +- Default key location: `$HOME\.ssh\id_ed25519`. +- The agent runs as a Windows service, not a per-shell process. + +### WSL + +WSL has its own ssh-agent independent of the Windows agent. Either: + +- Generate keys inside WSL and use `ssh-add` there, or +- Bridge to the Windows agent via `npiperelay` + `socat` (community guides + exist) โ€” beyond this doc's scope. + +When pointing brv at a key, use the WSL path (`/mnt/c/...` for Windows-side +files, plain `~/...` for WSL-side). --- -## 5. Kiแปƒm tra cแบฅu hรฌnh hiแป‡n tแบกi +## Existing git SSH signing config + +If you already configured `git config gpg.format ssh` and +`git config user.signingKey ...`, brv can import directly: ```bash -brv vc config user.signingkey # xem ฤ‘ฦฐแปng dแบซn key ฤ‘ang dรนng -brv vc config commit.sign # xem trแบกng thรกi tแปฑ ฤ‘แป™ng kรฝ +brv vc config --import-git-signing ``` +This reads `user.signingKey` and `commit.gpgSign` from your git config and +copies them into brv's project config โ€” no manual setup. + --- -## Nแบฟu ฤ‘รฃ cแบฅu hรฌnh SSH signing trong git +## Verification -Nแบฟu bแบกn ฤ‘รฃ chแบกy `git config gpg.format ssh` vร  `git config user.signingKey ...`, brv cรณ thแปƒ import trแปฑc tiแบฟp: +Verify any signed commit with the standard `git verify-commit`: ```bash -brv vc config --import-git-signing +# Build an allowed_signers file once +echo "you@example.com $(cat ~/.ssh/id_ed25519_signing.pub)" > ~/.config/brv/allowed_signers + +# Verify +cd .brv/context-tree +git -c gpg.ssh.allowedSignersFile=~/.config/brv/allowed_signers verify-commit HEAD ``` -Lแป‡nh nร y ฤ‘แปc `user.signingKey` vร  `commit.gpgSign` tแปซ git config hแป‡ thแป‘ng vร  รกp vร o brv โ€” khรดng cแบงn set thแปง cรดng. +Expected: `Good "git" signature for you@example.com with ED25519 key SHA256:...`. --- -## Xoรก key khรดng cรฒn dรนng +## Removing a key ```bash -brv signing-key list # lแบฅy key ID -brv signing-key remove # xoรก +brv signing-key list # list registered keys + IDs +brv signing-key remove # remove from ByteRover ``` +This deletes the public key from ByteRover only. The private key on disk and +any `brv vc config user.signingkey` setting are untouched. + --- -## Tรณm tแบฏt luแป“ng thiแบฟt lแบญp +## Troubleshooting -``` -ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing - โ†“ -brv signing-key add --key ~/.ssh/id_ed25519_signing --title "My laptop" - โ†“ -brv vc config user.signingkey ~/.ssh/id_ed25519_signing - โ†“ -brv vc config commit.sign true - โ†“ -brv vc commit -m "..." โ†’ tแปฑ ฤ‘แป™ng kรฝ โœ… -``` +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `Error: Encrypted OpenSSH private keys are not supported for direct signing.` | brv cannot decrypt OpenSSH-format encrypted keys natively. | Run `ssh-add ` to load the key into ssh-agent, then retry. | +| `Error: Unsupported OpenSSH key type: ssh-rsa` | RSA / ECDSA OpenSSH keys are not parsed natively. | Same โ€” load via `ssh-add`. | +| `Signing key requires a passphrase. Provide it via the --passphrase flag or BRV_SSH_PASSPHRASEโ€ฆ` | PEM-format key needs a passphrase and none was supplied. | Pass `--passphrase` or set `BRV_SSH_PASSPHRASE`. | +| `Could not verify signature.` from `git verify-commit` | `allowed_signers` file missing or wrong fingerprint. | Re-create as shown in **Verification**. | From c174c315d388ada1dd02f717b392198a72fbd00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 19:16:40 +0700 Subject: [PATCH 11/46] fix(ssh): narrow ERR_OSSL whitelist to passphrase-only codes (ENG-2002 C2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The B6 fix dropped /unsupported/ from the message-fallback regex but left the code-based check at `code.startsWith('ERR_OSSL')`, which still matches `ERR_OSSL_UNSUPPORTED`. Node.js crypto emits this code for any PEM body it cannot decode (malformed PKCS8, unsupported algorithm OIDs, garbage payload), so probeSSHKey continued to false-report needsPassphrase:true for any unparseable PEM file โ€” the same B6 class of bug on a different code path. Replace the prefix match with an explicit whitelist of the two codes that genuinely indicate a passphrase issue: ERR_OSSL_BAD_DECRYPT and ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED. Adds a regression test that constructs a minimal malformed PEM whose body is two characters of base64 garbage โ€” reliably surfaces ERR_OSSL_UNSUPPORTED across Node versions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/key-parser.ts | 20 ++++++++++++------ test/unit/infra/ssh/ssh-key-parser.test.ts | 24 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 831167302..00f7a4ecc 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -187,20 +187,28 @@ function opensshEd25519ToNodeKey(privateKeyBlob: Buffer): { function isPassphraseError(err: unknown): boolean { if (!(err instanceof Error)) return false - // Node.js crypto errors expose an `code` property (e.g., 'ERR_OSSL_BAD_DECRYPT') + // Node.js crypto errors expose an `code` property. Whitelist only codes that + // genuinely indicate a passphrase issue โ€” `ERR_OSSL_BAD_DECRYPT` (wrong/missing + // passphrase on an encrypted PEM) and `ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED` + // (Node-internal cancellation during prompt). A broader prefix match like + // `code.startsWith('ERR_OSSL')` would false-match `ERR_OSSL_UNSUPPORTED` + // (malformed/unparseable PEM) and other format-level failures, causing + // probeSSHKey to incorrectly return needsPassphrase:true for non-passphrase + // failures. const code = 'code' in err && typeof (err as {code: unknown}).code === 'string' ? (err as {code: string}).code : '' - if (code.startsWith('ERR_OSSL')) return true + if (code === 'ERR_OSSL_BAD_DECRYPT' || code === 'ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED') { + return true + } // Fallback: string matching for compatibility across Node.js/OpenSSL versions. // - // NOTE: Do NOT add `/unsupported/` here. Several callers (parseOpenSSHKey, + // Do NOT add `/unsupported/` here โ€” several callers (parseOpenSSHKey, // parseSSHPrivateKey) throw `"Unsupported OpenSSH key type: ..."` / // `"Unsupported PEM key type: ..."` for keys this build cannot parse natively. - // Including `/unsupported/` would false-match those errors and cause - // probeSSHKey to incorrectly report the key needs a passphrase, producing a - // spurious passphrase prompt instead of surfacing the real error. + // Matching that text would re-introduce the same false-positive that the + // code-based whitelist above is guarding against. const msg = err.message.toLowerCase() return /bad decrypt|passphrase|bad password|interrupted or cancelled/.test(msg) } diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 7fea47dbd..30073619a 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -121,6 +121,30 @@ describe('probeSSHKey()', () => { expect((caught as Error).message).to.match(/Unsupported OpenSSH key type/) }) + it('throws (does not false-prompt for passphrase) for malformed PEM that surfaces ERR_OSSL_UNSUPPORTED', async () => { + // Regression test for ENG-2002 C2 (incomplete B6 fix). Node.js crypto emits + // `ERR_OSSL_UNSUPPORTED` (not just `ERR_OSSL_BAD_DECRYPT`) when createPrivateKey + // hits a PEM body it cannot decode โ€” including malformed PKCS8, garbage payload, + // or unsupported algorithm OIDs. The original isPassphraseError used + // `code.startsWith('ERR_OSSL')` which matched ERR_OSSL_UNSUPPORTED and made + // probeSSHKey false-report needsPassphrase:true for any unparseable PEM. + // + // Two characters of base64 garbage inside a PEM envelope is the smallest + // reliable repro across Node versions. + const malformedPem = '-----BEGIN PRIVATE KEY-----\nQUFBQQ==\n-----END PRIVATE KEY-----' + const keyPath = join(tempDir, 'malformed_pem') + writeFileSync(keyPath, malformedPem, {mode: 0o600}) + + let caught: unknown + try { + await probeSSHKey(keyPath) + } catch (error) { + caught = error + } + + expect(caught, 'probeSSHKey must throw for malformed PEM, not return needsPassphrase:true').to.be.instanceOf(Error) + }) + it('throws "Unsupported OpenSSH key type" for unencrypted ECDSA OpenSSH key (does not false-prompt for passphrase)', async () => { // Same regression as above, but exercising ecdsa-sha2-nistp256. const pubBlob = Buffer.concat([sshStr('ecdsa-sha2-nistp256'), sshStr(Buffer.alloc(65, 0xaa))]) From 3b1d28cd2f1190594673198765d054f7fb1b5cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 19:17:55 +0700 Subject: [PATCH 12/46] refactor(ssh): consolidate SSHSIG_MAGIC + drop stale 7-byte comments (ENG-2002 C1+M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After B0 fix the envelope and signed-data preambles are both 6-byte "SSHSIG" โ€” having two named constants (SSHSIG_MAGIC and SSHSIG_SIGNED_DATA_MAGIC) for identical buffers misled future readers into thinking the spec required different prefixes. ssh-agent-signer.ts also still carried the pre-B0 inline comment "Build signed data (uses 7-byte magic WITH null terminator)" which now contradicted the code outright. - Export a single SSHSIG_MAGIC from sshsig-signer.ts and import it in ssh-agent-signer.ts (single source of truth). - Drop the stale 7-byte comment, replace with a one-line spec reference. - Remove the redundant SSHSIG_SIGNED_DATA_MAGIC alias. No behavior change. All 52 ssh unit tests still pass including the ssh-keygen round-trip verifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/ssh-agent-signer.ts | 9 +++------ src/server/infra/ssh/sshsig-signer.ts | 11 +++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index e7d77cf2e..570e332a3 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -4,6 +4,7 @@ import net from 'node:net' import type {SSHKeyType, SSHSignatureResult} from './types.js' import {getPublicKeyMetadata} from './ssh-key-parser.js' +import {SSHSIG_MAGIC} from './sshsig-signer.js' // SSH agent protocol message types const SSH_AGENTC_REQUEST_IDENTITIES = 11 @@ -176,19 +177,15 @@ export class SshAgentSigner { * Sign a commit payload using the ssh-agent, producing an armored sshsig signature. */ async sign(payload: string): Promise { - // PROTOCOL.sshsig defines MAGIC_PREAMBLE as `byte[6] "SSHSIG"` for both the - // envelope and the signed-data structure. See sshsig-signer.ts for the rationale. - const SSHSIG_MAGIC = Buffer.from('SSHSIG') - const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG') const NAMESPACE = 'git' const HASH_ALGORITHM = 'sha512' // 1. Hash commit payload const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() - // 2. Build signed data (uses 7-byte magic WITH null terminator) + // 2. Build signed-data structure per PROTOCOL.sshsig ยง2 (6-byte SSHSIG preamble) const signedData = Buffer.concat([ - SSHSIG_SIGNED_DATA_MAGIC, + SSHSIG_MAGIC, sshString(NAMESPACE), sshString(''), sshString(HASH_ALGORITHM), diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts index c6abeb035..5c1d2340a 100644 --- a/src/server/infra/ssh/sshsig-signer.ts +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -3,13 +3,12 @@ import {createHash, sign} from 'node:crypto' import type {ParsedSSHKey, SSHSignatureResult} from './types.js' // sshsig constants -// PROTOCOL.sshsig defines MAGIC_PREAMBLE as `byte[6] "SSHSIG"` for both the -// envelope and the signed-data structure. The OpenSSH C source uses -// `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes (no null terminator). +// PROTOCOL.sshsig defines MAGIC_PREAMBLE as `byte[6] "SSHSIG"` (no null +// terminator) for both the envelope and the signed-data structure. The OpenSSH +// C source uses `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes. // Adding the null byte produces signatures that fail `ssh-keygen -Y verify` // and `git verify-commit`. -const SSHSIG_MAGIC = Buffer.from('SSHSIG') -const SSHSIG_SIGNED_DATA_MAGIC = Buffer.from('SSHSIG') +export const SSHSIG_MAGIC = Buffer.from('SSHSIG') const SSHSIG_VERSION = 1 const NAMESPACE = 'git' const HASH_ALGORITHM = 'sha512' @@ -42,7 +41,7 @@ export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignat // 2. Build the "signed data" structure per PROTOCOL.sshsig ยง2 // This is what the private key actually signs โ€” NOT the raw payload. const signedData = Buffer.concat([ - SSHSIG_SIGNED_DATA_MAGIC, // "SSHSIG" (6 bytes, byte[6] per spec) + SSHSIG_MAGIC, // 6-byte preamble per spec sshString(NAMESPACE), // "git" sshString(''), // reserved (empty) sshString(HASH_ALGORITHM), // "sha512" From 7c9ba89dd070a84112e0e53b08844c4db2cb293f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 19:19:43 +0700 Subject: [PATCH 13/46] test(ssh): plug tempdir leaks + tighten B7 assertion (ENG-2002 M2+M3+M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test-quality fixes from review: M2: vc-handler.test.ts created tempdirs inline (brv-enc-key-test-*, brv-rsa-key-test-*) without cleanup, leaking one dir per run. Wrap both bodies in try/finally + rmSync. M3: B7 regression test wrapped the PASSPHRASE_REQUIRED-must-not-fire assertion in `if (error instanceof VcError)`. Because probeSSHKey now throws a plain Error for unsupported keytypes, that branch was skipped โ€” the assertion silently disappeared. Pin behavior with `expect(error).to.not.be.instanceOf(VcError)` so any future wrap into VcError is caught and reviewed deliberately. M5: ssh-key-parser.test.ts had beforeEach() that mkdtempSync'd a fresh tempdir for every test in three describe blocks but no matching afterEach/after. Added cleanup hooks to all three. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/unit/infra/ssh/ssh-key-parser.test.ts | 14 ++- .../transport/handlers/vc-handler.test.ts | 104 ++++++++++-------- 2 files changed, 69 insertions(+), 49 deletions(-) diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 30073619a..679e4d60a 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -1,5 +1,5 @@ import {expect} from 'chai' -import {mkdtempSync, writeFileSync} from 'node:fs' +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -56,6 +56,10 @@ describe('probeSSHKey()', () => { tempDir = mkdtempSync(join(tmpdir(), 'brv-ssh-test-')) }) + afterEach(() => { + rmSync(tempDir, {force: true, recursive: true}) + }) + it('returns {exists: false} for non-existent file', async () => { const result = await probeSSHKey(join(tempDir, 'missing_key')) expect(result).to.deep.equal({exists: false}) @@ -211,6 +215,10 @@ describe('parseSSHPrivateKey()', () => { writeFileSync(keyPath, TEST_OPENSSH_ED25519_KEY, {mode: 0o600}) }) + after(() => { + rmSync(tempDir, {force: true, recursive: true}) + }) + it('returns a ParsedSSHKey with correct shape', async () => { const parsed = await parseSSHPrivateKey(keyPath) @@ -266,6 +274,10 @@ describe('extractPublicKey()', () => { tempDir = mkdtempSync(join(tmpdir(), 'brv-extract-test-')) }) + afterEach(() => { + rmSync(tempDir, {force: true, recursive: true}) + }) + it('extracts public key from encrypted OpenSSH key with no .pub sidecar', async () => { const keyPath = join(tempDir, 'id_ed25519') writeFileSync(keyPath, makeEncryptedOpenSSHKey(), {mode: 0o600}) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 8b0007c4e..fa3068c74 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -792,31 +792,35 @@ describe('VcHandler', () => { ]) const encKeyPem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${encKeyBuf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` const realTmpDir = fs.mkdtempSync(join(tmpdir(), 'brv-enc-key-test-')) - const encKeyPath = join(realTmpDir, 'enc_key') - writeFileSync(encKeyPath, encKeyPem, {mode: 0o600}) + try { + const encKeyPath = join(realTmpDir, 'enc_key') + writeFileSync(encKeyPath, encKeyPem, {mode: 0o600}) - const deps = makeDeps(sandbox, projectPath) - deps.gitService.isInitialized.resolves(true) - deps.gitService.status.resolves({ - files: [{path: 'a.md', staged: true, status: 'added'}], - isClean: false, - }) - deps.vcGitConfigStore.get.resolves({ - commitSign: true, - email: 'test@example.com', - name: 'Test', - signingKey: encKeyPath, - }) - makeVcHandler(deps).setup() + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({ + files: [{path: 'a.md', staged: true, status: 'added'}], + isClean: false, + }) + deps.vcGitConfigStore.get.resolves({ + commitSign: true, + email: 'test@example.com', + name: 'Test', + signingKey: encKeyPath, + }) + makeVcHandler(deps).setup() - try { - await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) - expect.fail('Expected error') - } catch (error) { - expect(error).to.be.instanceOf(VcError) - if (error instanceof VcError) { - expect(error.code).to.equal(VcErrorCode.SIGNING_KEY_NOT_SUPPORTED) + try { + await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) + expect.fail('Expected error') + } catch (error) { + expect(error).to.be.instanceOf(VcError) + if (error instanceof VcError) { + expect(error.code).to.equal(VcErrorCode.SIGNING_KEY_NOT_SUPPORTED) + } } + } finally { + rmSync(realTmpDir, {force: true, recursive: true}) } }) @@ -853,35 +857,39 @@ describe('VcHandler', () => { ]) const rsaKeyPem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${rsaKeyBuf.toString('base64')}\n-----END OPENSSH PRIVATE KEY-----` const realTmpDir = fs.mkdtempSync(join(tmpdir(), 'brv-rsa-key-test-')) - const rsaKeyPath = join(realTmpDir, 'rsa_key') - writeFileSync(rsaKeyPath, rsaKeyPem, {mode: 0o600}) + try { + const rsaKeyPath = join(realTmpDir, 'rsa_key') + writeFileSync(rsaKeyPath, rsaKeyPem, {mode: 0o600}) - const deps = makeDeps(sandbox, projectPath) - deps.gitService.isInitialized.resolves(true) - deps.gitService.status.resolves({ - files: [{path: 'a.md', staged: true, status: 'added'}], - isClean: false, - }) - deps.vcGitConfigStore.get.resolves({ - commitSign: true, - email: 'test@example.com', - name: 'Test', - signingKey: rsaKeyPath, - }) - makeVcHandler(deps).setup() + const deps = makeDeps(sandbox, projectPath) + deps.gitService.isInitialized.resolves(true) + deps.gitService.status.resolves({ + files: [{path: 'a.md', staged: true, status: 'added'}], + isClean: false, + }) + deps.vcGitConfigStore.get.resolves({ + commitSign: true, + email: 'test@example.com', + name: 'Test', + signingKey: rsaKeyPath, + }) + makeVcHandler(deps).setup() - try { - await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) - expect.fail('Expected error') - } catch (error) { - expect(error).to.be.instanceOf(Error) - // The error must NOT be PASSPHRASE_REQUIRED โ€” that would re-trigger the - // CLI retry loop and produce spurious passphrase prompts. - if (error instanceof VcError) { - expect(error.code).to.not.equal(VcErrorCode.PASSPHRASE_REQUIRED) + try { + await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) + expect.fail('Expected error') + } catch (error) { + // After B6, probeSSHKey throws a plain Error (not a VcError) for + // unsupported keytypes. Pin both the type and the message so any + // future wrapping into VcError is caught and reviewed deliberately + // (a wrap that uses code=PASSPHRASE_REQUIRED would re-introduce the + // CLI retry loop this test guards against). + expect(error).to.be.instanceOf(Error) + expect(error).to.not.be.instanceOf(VcError) + expect((error as Error).message).to.match(/Unsupported OpenSSH key type/) } - - expect((error as Error).message).to.match(/Unsupported OpenSSH key type/) + } finally { + rmSync(realTmpDir, {force: true, recursive: true}) } }) }) From be121dda81cff4070aa66bfc72d3d0b4e1c6cfee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 19:23:39 +0700 Subject: [PATCH 14/46] style(ssh): minor cleanups from code review (ENG-2002 m1+m2+m3+m4+m6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - m1: drop `as {code: string}` assertions in commit.ts and key-parser.ts. TS narrows `err.code` correctly via `'code' in err && typeof err.code === 'string'`. - m2: remove ticket reference (ENG-2002 ยงSigning Flow step 2) from commit.ts comment โ€” explanation already conveys WHY. - m3: rename test payload "B0 round-trip" โ†’ "round-trip test payload" so it doesn't bake a task ID into test data. - m4: rephrase docs PEM/PKCS8 jargon as "the narrow exception" with a pointer to ssh-agent as the expected path for most users. - m6: drop `console.warn` from skipped-suite branch in sshsig round-trip test; Mocha already prints "pending" when this.skip() fires. No behavior change. All 52 ssh tests + typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ssh-commit-signing.md | 10 +++++++--- src/oclif/commands/vc/commit.ts | 10 +++++----- src/shared/ssh/key-parser.ts | 4 +--- test/unit/infra/ssh/sshsig-signer.test.ts | 3 +-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/ssh-commit-signing.md b/docs/ssh-commit-signing.md index 98ad7f608..be8f178ae 100644 --- a/docs/ssh-commit-signing.md +++ b/docs/ssh-commit-signing.md @@ -55,9 +55,13 @@ do not need to change any brv config. ## Passphrase-protected keys (without ssh-agent) -For Ed25519 keys whose private file lives unencrypted on disk you do not need a -passphrase. For any other passphrase-protected key, **use ssh-agent** (above). -Only Ed25519-via-PEM-PKCS8 supports passphrase entry directly: +For an unencrypted Ed25519 key on disk you do not need a passphrase. For any +other passphrase-protected key, **use ssh-agent** (above) โ€” that is the +expected path for the vast majority of users. + +The narrow exception is an Ed25519 key saved in legacy PEM/PKCS8 format +(rather than the modern OpenSSH format that `ssh-keygen` produces by +default). Keys in that format support direct passphrase entry: ```bash # Pass once via flag diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index d346a4113..38ad616ff 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -55,14 +55,14 @@ export default class VcCommit extends Command { const sigIndicator = result.signed ? ' ๐Ÿ”' : '' this.log(`[${result.sha.slice(0, 7)}] ${result.message}${sigIndicator}`) } catch (error) { - // oclif commands run non-interactively (no TUI). When the signing key is - // passphrase-protected and the user did not provide one, surface a clear - // actionable error instead of prompting โ€” passphrase entry belongs in the - // TUI's Ink layer (ENG-2002 ยงSigning Flow step 2). + // oclif commands run non-interactively. Surface a clear actionable error + // for missing passphrase instead of prompting โ€” interactive entry belongs + // in the TUI layer. if ( error instanceof Error && 'code' in error && - (error as {code: string}).code === VcErrorCode.PASSPHRASE_REQUIRED + typeof error.code === 'string' && + error.code === VcErrorCode.PASSPHRASE_REQUIRED ) { this.error( 'Signing key requires a passphrase. Provide it via the --passphrase flag ' + diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 00f7a4ecc..996b88b07 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -195,9 +195,7 @@ function isPassphraseError(err: unknown): boolean { // (malformed/unparseable PEM) and other format-level failures, causing // probeSSHKey to incorrectly return needsPassphrase:true for non-passphrase // failures. - const code = 'code' in err && typeof (err as {code: unknown}).code === 'string' - ? (err as {code: string}).code - : '' + const code = 'code' in err && typeof err.code === 'string' ? err.code : '' if (code === 'ERR_OSSL_BAD_DECRYPT' || code === 'ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED') { return true } diff --git a/test/unit/infra/ssh/sshsig-signer.test.ts b/test/unit/infra/ssh/sshsig-signer.test.ts index ed40eeabf..d4c6a0b57 100644 --- a/test/unit/infra/ssh/sshsig-signer.test.ts +++ b/test/unit/infra/ssh/sshsig-signer.test.ts @@ -195,7 +195,6 @@ describe('signCommitPayload()', () => { before(async function () { if (!isSshKeygenAvailable()) { - console.warn('[sshsig round-trip] ssh-keygen not on PATH โ€” skipping suite') this.skip() } @@ -210,7 +209,7 @@ describe('signCommitPayload()', () => { }) it('ssh-keygen -Y check-novalidate accepts the signature', () => { - const payload = 'tree abc123\nparent def456\nauthor Test 0 +0000\n\nB0 round-trip\n' + const payload = 'tree abc123\nparent def456\nauthor Test 0 +0000\n\nround-trip test payload\n' const {armored} = signCommitPayload(payload, roundtripKey) const sigPath = join(roundtripDir, 'commit.sig') From ab3787d17fd2c658cf3c20fc9f83fb4676009676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 20:25:03 +0700 Subject: [PATCH 15/46] docs(ssh): correct misleading comment for ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically (Node v24): createPrivateKey() on an encrypted PEM with NO passphrase argument throws ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED. The previous comment described it as "Node-internal cancellation during prompt", suggesting the code path only fires under interactive entry. That was wrong โ€” the code is the normal failure mode for the entire non-interactive encrypted-PEM-no-passphrase flow that brv relies on. Future maintainers reading the wrong comment could justify removing the whitelist entry as "dead code", which would break passphrase prompting for every encrypted PEM key. Add a regression test that constructs an encrypted Ed25519 PEM key, calls probeSSHKey with no passphrase, and asserts needsPassphrase:true โ€” pinning both the OpenSSL behavior and the corrected comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/key-parser.ts | 17 +++++++++-------- test/unit/infra/ssh/ssh-key-parser.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 996b88b07..617c1b54d 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -187,14 +187,15 @@ function opensshEd25519ToNodeKey(privateKeyBlob: Buffer): { function isPassphraseError(err: unknown): boolean { if (!(err instanceof Error)) return false - // Node.js crypto errors expose an `code` property. Whitelist only codes that - // genuinely indicate a passphrase issue โ€” `ERR_OSSL_BAD_DECRYPT` (wrong/missing - // passphrase on an encrypted PEM) and `ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED` - // (Node-internal cancellation during prompt). A broader prefix match like - // `code.startsWith('ERR_OSSL')` would false-match `ERR_OSSL_UNSUPPORTED` - // (malformed/unparseable PEM) and other format-level failures, causing - // probeSSHKey to incorrectly return needsPassphrase:true for non-passphrase - // failures. + // Node.js crypto errors expose an `code` property. Whitelist exactly the two + // OpenSSL codes that mean "passphrase is the problem": + // - ERR_OSSL_BAD_DECRYPT โ€” wrong / empty-string passphrase on encrypted PEM + // - ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED โ€” encrypted PEM with NO passphrase + // argument (OpenSSL aborts the read instead + // of erroring; misleadingly named) + // A broader prefix match like `code.startsWith('ERR_OSSL')` would false-match + // `ERR_OSSL_UNSUPPORTED` (malformed/unparseable PEM) and other format-level + // failures, causing probeSSHKey to incorrectly return needsPassphrase:true. const code = 'code' in err && typeof err.code === 'string' ? err.code : '' if (code === 'ERR_OSSL_BAD_DECRYPT' || code === 'ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED') { return true diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 679e4d60a..a8f9fc065 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -1,4 +1,5 @@ import {expect} from 'chai' +import {generateKeyPairSync} from 'node:crypto' import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -95,6 +96,25 @@ describe('probeSSHKey()', () => { expect(result.opensshEncrypted).to.be.true }) + it('returns needsPassphrase:true for an encrypted PEM key with no passphrase argument', async () => { + // Pins the no-passphrase code path: createPrivateKey on an encrypted PEM + // emits ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED (NOT a "user cancelled + // prompt" โ€” OpenSSL just aborts the read). isPassphraseError must catch + // this code, otherwise probeSSHKey would surface a raw crypto error + // instead of asking the caller for a passphrase. + const {privateKey} = generateKeyPairSync('ed25519', { + privateKeyEncoding: {cipher: 'aes-256-cbc', format: 'pem', passphrase: 'secret', type: 'pkcs8'}, + publicKeyEncoding: {format: 'pem', type: 'spki'}, + }) + const keyPath = join(tempDir, 'id_ed25519_pem_enc') + writeFileSync(keyPath, privateKey, {mode: 0o600}) + + const result = await probeSSHKey(keyPath) + expect(result.exists).to.be.true + if (!result.exists) throw new Error('unreachable') + expect(result.needsPassphrase).to.be.true + }) + it('throws "Unsupported OpenSSH key type" for unencrypted RSA OpenSSH key (does not false-prompt for passphrase)', async () => { // Regression test for ENG-2002 B6: the isPassphraseError regex used to include // /unsupported/, which false-matched parseOpenSSHKey's own error string and From d02c5f8be94c0c56bf1e2158c9a3b635466f9ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 20:26:07 +0700 Subject: [PATCH 16/46] refactor(ssh): extract SSHSIG_MAGIC into dedicated sshsig-constants module The previous setup exported SSHSIG_MAGIC from sshsig-signer.ts and imported it into ssh-agent-signer.ts. That leaked an implementation detail (the spec preamble byte sequence) onto the public surface of a module whose responsibility is signing, not constants. Move SSHSIG_MAGIC into a tiny sshsig-constants.ts dedicated to PROTOCOL.sshsig spec literals. Both signers import from it equally, no circular imports, and the spec context lives in one place where parser / verifier code can join it later if needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/ssh-agent-signer.ts | 2 +- src/server/infra/ssh/sshsig-constants.ts | 9 +++++++++ src/server/infra/ssh/sshsig-signer.ts | 9 ++------- 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 src/server/infra/ssh/sshsig-constants.ts diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index 570e332a3..b65034a39 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -4,7 +4,7 @@ import net from 'node:net' import type {SSHKeyType, SSHSignatureResult} from './types.js' import {getPublicKeyMetadata} from './ssh-key-parser.js' -import {SSHSIG_MAGIC} from './sshsig-signer.js' +import {SSHSIG_MAGIC} from './sshsig-constants.js' // SSH agent protocol message types const SSH_AGENTC_REQUEST_IDENTITIES = 11 diff --git a/src/server/infra/ssh/sshsig-constants.ts b/src/server/infra/ssh/sshsig-constants.ts new file mode 100644 index 000000000..3d0c076b6 --- /dev/null +++ b/src/server/infra/ssh/sshsig-constants.ts @@ -0,0 +1,9 @@ +// PROTOCOL.sshsig spec constants. See: +// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig +// +// MAGIC_PREAMBLE is `byte[6] "SSHSIG"` (no null terminator) for both the +// envelope and the signed-data structure. The OpenSSH C source uses +// `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes. Adding the null +// byte produces signatures that fail `ssh-keygen -Y verify` and +// `git verify-commit`. +export const SSHSIG_MAGIC = Buffer.from('SSHSIG') diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts index 5c1d2340a..4218df5b5 100644 --- a/src/server/infra/ssh/sshsig-signer.ts +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -2,13 +2,8 @@ import {createHash, sign} from 'node:crypto' import type {ParsedSSHKey, SSHSignatureResult} from './types.js' -// sshsig constants -// PROTOCOL.sshsig defines MAGIC_PREAMBLE as `byte[6] "SSHSIG"` (no null -// terminator) for both the envelope and the signed-data structure. The OpenSSH -// C source uses `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes. -// Adding the null byte produces signatures that fail `ssh-keygen -Y verify` -// and `git verify-commit`. -export const SSHSIG_MAGIC = Buffer.from('SSHSIG') +import {SSHSIG_MAGIC} from './sshsig-constants.js' + const SSHSIG_VERSION = 1 const NAMESPACE = 'git' const HASH_ALGORITHM = 'sha512' From 0bf6b0766059e0b43638d8183166ebc100101b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 20:26:58 +0700 Subject: [PATCH 17/46] test(ssh): replace `as Error`, justify before-pattern, drop B0 reference (review-2) Three small test-quality fixes from the second review pass: - vc-handler B7 test: replace `(error as Error).message` with a proper `if (!(error instanceof Error)) expect.fail(...)` narrowing pattern so the assertion no longer relies on a CLAUDE.md-banned `as` cast. - ssh-key-parser parseSSHPrivateKey: add a one-line comment explaining why this describe block uses before/after instead of beforeEach (every test is read-only against keyPath; recreating per-test is wasted I/O). - sshsig-signer round-trip suite header: drop the lingering "B0/ENG-2002" bug-tag reference; reword to describe the trap more generally so the comment is still meaningful after the ticket is closed. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/unit/infra/ssh/ssh-key-parser.test.ts | 3 +++ test/unit/infra/ssh/sshsig-signer.test.ts | 8 ++++---- test/unit/infra/transport/handlers/vc-handler.test.ts | 6 +++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index a8f9fc065..99d22f4f0 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -229,6 +229,9 @@ describe('parseSSHPrivateKey()', () => { let tempDir: string let keyPath: string + // Single setup: every test in this block is read-only against keyPath, so + // before/after (lifecycle once) is correct โ€” beforeEach would re-create the + // key file unnecessarily and slow the suite. before(() => { tempDir = mkdtempSync(join(tmpdir(), 'brv-ssh-parse-test-')) keyPath = join(tempDir, 'id_ed25519') diff --git a/test/unit/infra/ssh/sshsig-signer.test.ts b/test/unit/infra/ssh/sshsig-signer.test.ts index d4c6a0b57..2d84a69f0 100644 --- a/test/unit/infra/ssh/sshsig-signer.test.ts +++ b/test/unit/infra/ssh/sshsig-signer.test.ts @@ -184,10 +184,10 @@ describe('signCommitPayload()', () => { }) // Round-trip suite: feed our armored signature back to the OpenSSH reference - // verifier (`ssh-keygen -Y check-novalidate`) and assert acceptance. This is the - // only test in the file that validates against an external verifier โ€” every other - // assertion is structural and can pass while the signature is cryptographically - // invalid (which is exactly how B0/ENG-2002 slipped past the original reviews). + // verifier (`ssh-keygen -Y check-novalidate`) and assert acceptance. This is + // the only test in the file that validates against an external verifier โ€” + // every other assertion is structural and can pass while the signature is + // cryptographically invalid (the trap earlier reviews of this code missed). describe('round-trip with ssh-keygen', () => { let roundtripDir: string let roundtripKeyPath: string diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index fa3068c74..3555d973e 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -886,7 +886,11 @@ describe('VcHandler', () => { // CLI retry loop this test guards against). expect(error).to.be.instanceOf(Error) expect(error).to.not.be.instanceOf(VcError) - expect((error as Error).message).to.match(/Unsupported OpenSSH key type/) + if (!(error instanceof Error)) { + expect.fail('error is not an Error instance') + } + + expect(error.message).to.match(/Unsupported OpenSSH key type/) } } finally { rmSync(realTmpDir, {force: true, recursive: true}) From bb9dd0eaca881773db47a700b2a70005b8109f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 20:27:38 +0700 Subject: [PATCH 18/46] docs(ssh): align troubleshooting symptoms with actual error strings (review-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom column previously truncated each error mid-sentence (e.g. 'Error: Encrypted OpenSSH private keys are not supported for direct signing.' ending at the first period), so a user grepping their terminal output for the documented string would not get an exact match and might assume the doc covered a different error. Reword to show the actual leading substring of each error verbatim, ending with `โ€ฆ` to mark the tail (key path or key type) that varies per invocation. Add a one-line note above the table making the prefix match convention explicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ssh-commit-signing.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/ssh-commit-signing.md b/docs/ssh-commit-signing.md index be8f178ae..ac9e948f7 100644 --- a/docs/ssh-commit-signing.md +++ b/docs/ssh-commit-signing.md @@ -149,9 +149,12 @@ any `brv vc config user.signingkey` setting are untouched. ## Troubleshooting -| Symptom | Likely cause | Fix | +Symptom column shows the leading substring of each error โ€” the actual +output continues with a key path or key-type detail. Match by prefix. + +| Symptom (starts with) | Likely cause | Fix | | --- | --- | --- | -| `Error: Encrypted OpenSSH private keys are not supported for direct signing.` | brv cannot decrypt OpenSSH-format encrypted keys natively. | Run `ssh-add ` to load the key into ssh-agent, then retry. | -| `Error: Unsupported OpenSSH key type: ssh-rsa` | RSA / ECDSA OpenSSH keys are not parsed natively. | Same โ€” load via `ssh-add`. | -| `Signing key requires a passphrase. Provide it via the --passphrase flag or BRV_SSH_PASSPHRASEโ€ฆ` | PEM-format key needs a passphrase and none was supplied. | Pass `--passphrase` or set `BRV_SSH_PASSPHRASE`. | +| `Error: Encrypted OpenSSH private keys are not supported for direct signing. Load the key into ssh-agent first: ssh-add โ€ฆ` | brv cannot decrypt OpenSSH-format encrypted keys natively. | Run the `ssh-add` command from the error message, then retry. | +| `Error: Unsupported OpenSSH key type: โ€ฆ` | RSA / ECDSA / non-Ed25519 OpenSSH keys are not parsed natively. | Load the key into ssh-agent (`ssh-add `). | +| `Error: Signing key requires a passphrase. Provide it via the --passphrase flag or the BRV_SSH_PASSPHRASE environment variable, then retry.` | PEM-format key needs a passphrase and none was supplied. | Pass `--passphrase` or set `BRV_SSH_PASSPHRASE`. | | `Could not verify signature.` from `git verify-commit` | `allowed_signers` file missing or wrong fingerprint. | Re-create as shown in **Verification**. | From dd53ab90bd6b8f759cf4192b03e3e3822b0b648d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 20:42:21 +0700 Subject: [PATCH 19/46] test(ssh): proper narrowing instead of dead-guard `as Error` (review-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test-quality fixes from the third review pass: CRITICAL: vc-handler B7 test had `expect(error).to.be.instanceOf(Error)` followed by `if (!(error instanceof Error)) expect.fail(...)`. Because the Chai assertion already throws, the if-block was 100% dead โ€” TS narrowing was satisfied but the guard never ran. Restructure so the `expect.fail`-based narrowing comes first; subsequent assertions then operate on a properly typed Error instance. MAJOR: ssh-key-parser RSA and ECDSA "unsupported key type" tests still contained `(caught as Error).message` โ€” bypassed the same review-2 fix that landed in vc-handler. Apply the same `expect.fail` narrowing pattern so all three test sites are consistent. MINOR: parseSSHPrivateKey before/after comment now warns future contributors that tests in this block must stay read-only against keyPath; if mutation is needed, use a separate per-test tempDir. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/unit/infra/ssh/ssh-key-parser.test.ts | 17 ++++++++++++----- .../infra/transport/handlers/vc-handler.test.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 99d22f4f0..fcd6bece1 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -141,8 +141,11 @@ describe('probeSSHKey()', () => { caught = error } - expect(caught, 'probeSSHKey must throw for unsupported key type, not return needsPassphrase:true').to.be.instanceOf(Error) - expect((caught as Error).message).to.match(/Unsupported OpenSSH key type/) + if (!(caught instanceof Error)) { + expect.fail('probeSSHKey must throw for unsupported key type, not return needsPassphrase:true') + } + + expect(caught.message).to.match(/Unsupported OpenSSH key type/) }) it('throws (does not false-prompt for passphrase) for malformed PEM that surfaces ERR_OSSL_UNSUPPORTED', async () => { @@ -192,8 +195,11 @@ describe('probeSSHKey()', () => { caught = error } - expect(caught).to.be.instanceOf(Error) - expect((caught as Error).message).to.match(/Unsupported OpenSSH key type/) + if (!(caught instanceof Error)) { + expect.fail(`Expected Error, got ${typeof caught}`) + } + + expect(caught.message).to.match(/Unsupported OpenSSH key type/) }) it('returns {exists: true, needsPassphrase: true} for encrypted OpenSSH key', async () => { @@ -231,7 +237,8 @@ describe('parseSSHPrivateKey()', () => { // Single setup: every test in this block is read-only against keyPath, so // before/after (lifecycle once) is correct โ€” beforeEach would re-create the - // key file unnecessarily and slow the suite. + // key file unnecessarily and slow the suite. New tests added here MUST stay + // read-only; if you need to mutate, use a separate tempDir per test. before(() => { tempDir = mkdtempSync(join(tmpdir(), 'brv-ssh-parse-test-')) keyPath = join(tempDir, 'id_ed25519') diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 3555d973e..f1f96a160 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -878,18 +878,18 @@ describe('VcHandler', () => { try { await deps.requestHandlers[VcEvents.COMMIT]({message: 'signed commit', sign: true}, CLIENT_ID) expect.fail('Expected error') - } catch (error) { + } catch (error: unknown) { + // Narrow first so subsequent property access doesn't need `as`. + if (!(error instanceof Error)) { + expect.fail(`Expected Error instance, got ${typeof error}`) + } + // After B6, probeSSHKey throws a plain Error (not a VcError) for // unsupported keytypes. Pin both the type and the message so any // future wrapping into VcError is caught and reviewed deliberately // (a wrap that uses code=PASSPHRASE_REQUIRED would re-introduce the // CLI retry loop this test guards against). - expect(error).to.be.instanceOf(Error) expect(error).to.not.be.instanceOf(VcError) - if (!(error instanceof Error)) { - expect.fail('error is not an Error instance') - } - expect(error.message).to.match(/Unsupported OpenSSH key type/) } } finally { From ba0efb1717024ba319f4a65cc6a5f08c81e2e4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 20:45:23 +0700 Subject: [PATCH 20/46] refactor(ssh): export SSHSIG preamble as immutable string, not shared Buffer (review-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buffer is index-mutable in Node.js โ€” `MAGIC[0] = 0` is legal and silently corrupts every signature thereafter. The previous `export const SSHSIG_MAGIC = Buffer.from('SSHSIG')` shared one Buffer reference across both signers, so any accidental write anywhere in the process would taint both. For a cryptographic protocol constant that's unacceptable. Export the source string instead. Each signer module converts to its own local Buffer at module load โ€” fresh allocation, no cross-module mutation risk. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/ssh-agent-signer.ts | 4 +++- src/server/infra/ssh/sshsig-constants.ts | 7 ++++++- src/server/infra/ssh/sshsig-signer.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index b65034a39..abc64d164 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -4,7 +4,9 @@ import net from 'node:net' import type {SSHKeyType, SSHSignatureResult} from './types.js' import {getPublicKeyMetadata} from './ssh-key-parser.js' -import {SSHSIG_MAGIC} from './sshsig-constants.js' +import {SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' + +const SSHSIG_MAGIC = Buffer.from(SSHSIG_MAGIC_PREAMBLE) // SSH agent protocol message types const SSH_AGENTC_REQUEST_IDENTITIES = 11 diff --git a/src/server/infra/ssh/sshsig-constants.ts b/src/server/infra/ssh/sshsig-constants.ts index 3d0c076b6..82cc20880 100644 --- a/src/server/infra/ssh/sshsig-constants.ts +++ b/src/server/infra/ssh/sshsig-constants.ts @@ -6,4 +6,9 @@ // `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes. Adding the null // byte produces signatures that fail `ssh-keygen -Y verify` and // `git verify-commit`. -export const SSHSIG_MAGIC = Buffer.from('SSHSIG') +// +// Exposed as a string literal rather than a shared Buffer because Buffer is +// indexable-mutable โ€” a single misplaced `MAGIC[0] = 0` anywhere in the +// process would silently corrupt every signature thereafter. Each caller +// converts to its own Buffer at module load. +export const SSHSIG_MAGIC_PREAMBLE = 'SSHSIG' diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts index 4218df5b5..918eb7c1b 100644 --- a/src/server/infra/ssh/sshsig-signer.ts +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -2,8 +2,9 @@ import {createHash, sign} from 'node:crypto' import type {ParsedSSHKey, SSHSignatureResult} from './types.js' -import {SSHSIG_MAGIC} from './sshsig-constants.js' +import {SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' +const SSHSIG_MAGIC = Buffer.from(SSHSIG_MAGIC_PREAMBLE) const SSHSIG_VERSION = 1 const NAMESPACE = 'git' const HASH_ALGORITHM = 'sha512' From e5a8f992502f8a01921e40d43189b8db2ca3ac16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 21:17:42 +0700 Subject: [PATCH 21/46] chore(ssh): correct constants comment + consolidate dynamic import (review-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small follow-ups from the fourth review pass: - sshsig-constants.ts: previous comment claimed "each caller converts to its own Buffer at module load" which implied per-call allocation. In reality each importing module materialises one module-private Buffer once at load. Reword so the safety claim matches the actual behavior โ€” the Buffer is per-module, not per-call. - ssh-key-parser.test.ts: a leftover `await import('node:crypto')` inside `it('privateKeyObject is a valid KeyObject that can sign')` remained from before review-2's static `generateKeyPairSync` import landed. Hoist `sign` into the same static import line. - ssh-key-parser.test.ts: the ECDSA "unsupported key type" test used a technical fail message (`Expected Error, got ${typeof caught}`) while the sibling RSA test used a behavior-contract message. Make them consistent (behavior contract wins โ€” it explains why the test exists, not just what failed). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/sshsig-constants.ts | 13 +++++-------- test/unit/infra/ssh/ssh-key-parser.test.ts | 5 ++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/server/infra/ssh/sshsig-constants.ts b/src/server/infra/ssh/sshsig-constants.ts index 82cc20880..8c2bf783e 100644 --- a/src/server/infra/ssh/sshsig-constants.ts +++ b/src/server/infra/ssh/sshsig-constants.ts @@ -2,13 +2,10 @@ // https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig // // MAGIC_PREAMBLE is `byte[6] "SSHSIG"` (no null terminator) for both the -// envelope and the signed-data structure. The OpenSSH C source uses -// `MAGIC_PREAMBLE_LEN = sizeof("SSHSIG") - 1`, i.e. 6 bytes. Adding the null -// byte produces signatures that fail `ssh-keygen -Y verify` and -// `git verify-commit`. +// envelope and the signed-data structure. Adding a null byte produces +// signatures that fail `ssh-keygen -Y verify` and `git verify-commit`. // -// Exposed as a string literal rather than a shared Buffer because Buffer is -// indexable-mutable โ€” a single misplaced `MAGIC[0] = 0` anywhere in the -// process would silently corrupt every signature thereafter. Each caller -// converts to its own Buffer at module load. +// Exported as a string rather than a Buffer so the spec value cannot be +// mutated across module boundaries โ€” Buffer is indexable-mutable. Each +// importing module materialises its own module-private Buffer once at load. export const SSHSIG_MAGIC_PREAMBLE = 'SSHSIG' diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index fcd6bece1..01c81bc10 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -1,5 +1,5 @@ import {expect} from 'chai' -import {generateKeyPairSync} from 'node:crypto' +import {generateKeyPairSync, sign} from 'node:crypto' import {mkdtempSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -196,7 +196,7 @@ describe('probeSSHKey()', () => { } if (!(caught instanceof Error)) { - expect.fail(`Expected Error, got ${typeof caught}`) + expect.fail('probeSSHKey must throw for unsupported key type, not return needsPassphrase:true') } expect(caught.message).to.match(/Unsupported OpenSSH key type/) @@ -274,7 +274,6 @@ describe('parseSSHPrivateKey()', () => { }) it('privateKeyObject is a valid KeyObject that can sign', async () => { - const {sign} = await import('node:crypto') const parsed = await parseSSHPrivateKey(keyPath) // Ed25519 uses sign(null, data, key) โ€” algorithm is implicit From c13397d45a3d8e6786539f70abbcf6f274dbd642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Mon, 20 Apr 2026 23:56:04 +0700 Subject: [PATCH 22/46] fix(ssh): user-grade errors for wrong format and wrong passphrase (ENG-2002 AC9-b/c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end verification revealed that two AC9 paths still surface raw OpenSSL codes to the user: - AC9-b (wrong format): a non-key file at user.signingkey causes `createPrivateKey` to throw `error:1E08010C:DECODER routines::unsupported` during config validation. The raw error propagates unchanged. - AC9-c (wrong passphrase): a valid encrypted PEM with the wrong passphrase surfaces `error:1C800064:Provider routines::bad decrypt`. Introduce a small `formatCryptoError` helper that maps the two known OpenSSL codes to actionable English and leaves unknown errors untouched (so rare failures still surface for debugging): - ERR_OSSL_UNSUPPORTED โ†’ "File at is not a valid SSH private key (unrecognised or malformed PEM / PKCS8 body)." - ERR_OSSL_BAD_DECRYPT โ†’ "Wrong passphrase for SSH key at ." Called from the probeSSHKey outer catch (after passphrase detection so encrypted-PEM-no-passphrase still returns needsPassphrase:true) and around the PEM createPrivateKey call in parseSSHPrivateKey. Regression tests assert the new messages and that neither contains the raw OpenSSL code fragment. End-to-end smoke verified against a real brv vc commit flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/key-parser.ts | 45 +++++++++++++++++--- test/unit/infra/ssh/ssh-key-parser.test.ts | 49 +++++++++++++++++----- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 617c1b54d..699306620 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -212,6 +212,34 @@ function isPassphraseError(err: unknown): boolean { return /bad decrypt|passphrase|bad password|interrupted or cancelled/.test(msg) } +/** + * Translate a Node.js crypto error into a user-grade Error when the code + * matches a known cause. Otherwise returns the original error untouched so + * unrecognised failures still surface for debugging. + * + * Purpose: the raw OpenSSL messages (`error:1C800064:Provider routines::bad + * decrypt`, `error:1E08010C:DECODER routines::unsupported`) are unreadable to + * end users. AC9-b and AC9-c of ENG-2002 require actionable English. + */ +function formatCryptoError(err: unknown, keyPath: string): Error { + if (!(err instanceof Error)) return new Error(String(err)) + + const code = 'code' in err && typeof err.code === 'string' ? err.code : '' + + if (code === 'ERR_OSSL_BAD_DECRYPT') { + return new Error(`Wrong passphrase for SSH key at ${keyPath}.`) + } + + if (code === 'ERR_OSSL_UNSUPPORTED') { + return new Error( + `File at ${keyPath} is not a valid SSH private key ` + + `(unrecognised or malformed PEM / PKCS8 body).`, + ) + } + + return err +} + // โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** @@ -256,7 +284,7 @@ export async function probeSSHKey(keyPath: string): Promise { return {exists: true, needsPassphrase: true} } - throw error + throw formatCryptoError(error, keyPath) } } @@ -314,11 +342,16 @@ export async function parseSSHPrivateKey( } // โ”€โ”€ Standard PEM format (PKCS8, RSA, ECDSA) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const privateKeyObject = createPrivateKey({ - format: 'pem', - key: raw, - ...(passphrase ? {passphrase} : {}), - }) + let privateKeyObject + try { + privateKeyObject = createPrivateKey({ + format: 'pem', + key: raw, + ...(passphrase ? {passphrase} : {}), + }) + } catch (error: unknown) { + throw formatCryptoError(error, keyPath) + } const publicKey = createPublicKey(privateKeyObject) diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index 01c81bc10..be98d5511 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -148,16 +148,11 @@ describe('probeSSHKey()', () => { expect(caught.message).to.match(/Unsupported OpenSSH key type/) }) - it('throws (does not false-prompt for passphrase) for malformed PEM that surfaces ERR_OSSL_UNSUPPORTED', async () => { - // Regression test for ENG-2002 C2 (incomplete B6 fix). Node.js crypto emits - // `ERR_OSSL_UNSUPPORTED` (not just `ERR_OSSL_BAD_DECRYPT`) when createPrivateKey - // hits a PEM body it cannot decode โ€” including malformed PKCS8, garbage payload, - // or unsupported algorithm OIDs. The original isPassphraseError used - // `code.startsWith('ERR_OSSL')` which matched ERR_OSSL_UNSUPPORTED and made - // probeSSHKey false-report needsPassphrase:true for any unparseable PEM. - // - // Two characters of base64 garbage inside a PEM envelope is the smallest - // reliable repro across Node versions. + it('throws a user-grade error (not raw OpenSSL code) for a file that is not a valid SSH key', async () => { + // Regression test for ENG-2002 AC9-b + C2. Node.js crypto emits + // ERR_OSSL_UNSUPPORTED for any PEM body it cannot decode. The raw error + // ("error:1E08010C:DECODER routines::unsupported") is not user-grade; + // probeSSHKey must translate it to actionable English. const malformedPem = '-----BEGIN PRIVATE KEY-----\nQUFBQQ==\n-----END PRIVATE KEY-----' const keyPath = join(tempDir, 'malformed_pem') writeFileSync(keyPath, malformedPem, {mode: 0o600}) @@ -169,7 +164,12 @@ describe('probeSSHKey()', () => { caught = error } - expect(caught, 'probeSSHKey must throw for malformed PEM, not return needsPassphrase:true').to.be.instanceOf(Error) + if (!(caught instanceof Error)) { + expect.fail('probeSSHKey must throw for malformed PEM, not return needsPassphrase:true') + } + + expect(caught.message).to.match(/not a valid SSH private key/i) + expect(caught.message).to.not.match(/ERR_OSSL|DECODER routines/) }) it('throws "Unsupported OpenSSH key type" for unencrypted ECDSA OpenSSH key (does not false-prompt for passphrase)', async () => { @@ -292,6 +292,33 @@ describe('parseSSHPrivateKey()', () => { expect(threw).to.be.true }) + + it('throws a user-grade error (not raw OpenSSL code) when given a wrong passphrase for an encrypted PEM key', async () => { + // Regression test for ENG-2002 AC9-c. createPrivateKey with a wrong + // passphrase emits ERR_OSSL_BAD_DECRYPT whose raw message + // ("error:1C800064:Provider routines::bad decrypt") is not user-grade; + // parseSSHPrivateKey must translate it to actionable English. + const {privateKey} = generateKeyPairSync('ed25519', { + privateKeyEncoding: {cipher: 'aes-256-cbc', format: 'pem', passphrase: 'rightpass', type: 'pkcs8'}, + publicKeyEncoding: {format: 'pem', type: 'spki'}, + }) + const encKeyPath = join(tempDir, 'id_ed25519_pem_wrongpp') + writeFileSync(encKeyPath, privateKey, {mode: 0o600}) + + let caught: unknown + try { + await parseSSHPrivateKey(encKeyPath, 'wrongpass') + } catch (error) { + caught = error + } + + if (!(caught instanceof Error)) { + expect.fail('parseSSHPrivateKey must throw when given a wrong passphrase') + } + + expect(caught.message).to.match(/passphrase/i) + expect(caught.message).to.not.match(/ERR_OSSL|Provider routines|bad decrypt/i) + }) }) // โ”€โ”€ extractPublicKey tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From 010e653865f366a17bf68273e02e80d0a2c0741e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 00:21:11 +0700 Subject: [PATCH 23/46] =?UTF-8?q?feat(tui):=20Ink=20passphrase=20prompt=20?= =?UTF-8?q?for=20encrypted=20SSH=20signing=20keys=20(ENG-2002=20=C2=A7Sign?= =?UTF-8?q?ing=20Flow=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oclif `vc commit` command deliberately has no interactive prompt (ENG-2002 B1 per Bao's "oclif is non-UI" rule), so users on encrypted keys must supply `--passphrase` or BRV_SSH_PASSPHRASE. The TUI โ€” which does support interactive input โ€” must still fulfil ticket ยงSigning Flow step 2 "prompt user for passphrase (Ink input)". Adds: - InlinePassword (src/tui/components/inline-prompts/inline-password.tsx) โ€” masked-input variant of InlineInput. Renders `*` per character, never writes the raw value to the Ink text stream, supports Escape for cancellation and rejects empty submissions. - vc-commit-flow-state.ts โ€” pure reducer for the commit flow state machine (committing โ†’ awaiting-passphrase โ†’ done). Split out of the React component so the transitions are unit-testable without spinning up an Ink tree. Caps retries at MAX_PASSPHRASE_RETRIES=3 (matches the pre-B1 oclif limit). - VcCommitFlow rewired to useReducer(reduceCommitFlow). When the daemon returns PASSPHRASE_REQUIRED, state transitions to awaiting-passphrase, InlinePassword renders, and submitting fires another commit mutation with the passphrase in the payload. Cancel on Escape yields "Passphrase entry cancelled." Tests: 13 unit tests cover the reducer (happy path, retry cap, terminal absorbing, out-of-order events, signed/unsigned SHA formatting). The component itself is not rendered in tests โ€” this project has no .tsx test files and no ink-testing-library dependency, consistent with its convention of testing logic rather than Ink trees. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tui/components/inline-prompts/index.ts | 2 + .../inline-prompts/inline-password.tsx | 84 ++++++++++ .../commit/components/vc-commit-flow-state.ts | 83 ++++++++++ .../vc/commit/components/vc-commit-flow.tsx | 90 ++++++++--- .../vc/commit/vc-commit-flow-state.test.ts | 148 ++++++++++++++++++ 5 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 src/tui/components/inline-prompts/inline-password.tsx create mode 100644 src/tui/features/vc/commit/components/vc-commit-flow-state.ts create mode 100644 test/unit/tui/features/vc/commit/vc-commit-flow-state.test.ts diff --git a/src/tui/components/inline-prompts/index.ts b/src/tui/components/inline-prompts/index.ts index 6a6c14241..025f1ab46 100644 --- a/src/tui/components/inline-prompts/index.ts +++ b/src/tui/components/inline-prompts/index.ts @@ -10,6 +10,8 @@ export {InlineFileSelector} from './inline-file-selector.js' export type {InlineFileSelectorProps} from './inline-file-selector.js' export {InlineInput} from './inline-input.js' export type {InlineInputProps} from './inline-input.js' +export {InlinePassword} from './inline-password.js' +export type {InlinePasswordProps} from './inline-password.js' export {InlineSearch} from './inline-search.js' export type {InlineSearchProps} from './inline-search.js' export {InlineSelect} from './inline-select.js' diff --git a/src/tui/components/inline-prompts/inline-password.tsx b/src/tui/components/inline-prompts/inline-password.tsx new file mode 100644 index 000000000..1ffea6abf --- /dev/null +++ b/src/tui/components/inline-prompts/inline-password.tsx @@ -0,0 +1,84 @@ +/** + * InlinePassword Component + * + * Masked-input variant of InlineInput for secrets (e.g., SSH key passphrases). + * + * Renders `*` for each typed character; the real value is never written to + * the Ink text stream. Uses ink's useInput directly (same rationale as + * InlineInput โ€” paste chunks arriving in a single React batch must + * accumulate via a functional state updater). + */ + +import {Box, Text, useInput} from 'ink' +import React, {useCallback, useState} from 'react' + +import {useTheme} from '../../hooks/index.js' +import {stripBracketedPaste} from '../../utils/index.js' + +export interface InlinePasswordProps { + /** The prompt message shown before the masked field */ + message: string + /** Escape-key handler (caller typically aborts the surrounding flow) */ + onCancel?: () => void + /** Called with the raw (unmasked) value when user presses Enter */ + onSubmit: (value: string) => void +} + +export function InlinePassword({ + message, + onCancel, + onSubmit, +}: InlinePasswordProps): React.ReactElement { + const { + theme: {colors}, + } = useTheme() + const [value, setValue] = useState('') + + const handleSubmit = useCallback(() => { + setValue((currentValue) => { + const cleaned = stripBracketedPaste(currentValue) + // Empty passphrase is almost always a mistake; do not submit. + if (cleaned.length === 0) return currentValue + setTimeout(() => onSubmit(cleaned), 0) + return currentValue + }) + }, [onSubmit]) + + useInput((input, key) => { + if (key.escape) { + if (onCancel) onCancel() + return + } + + if (key.upArrow || key.downArrow || key.tab || (key.shift && key.tab)) { + return + } + + if (key.return) { + handleSubmit() + return + } + + if (key.backspace || key.delete) { + setValue((prev) => prev.slice(0, -1)) + return + } + + if (input && !key.ctrl && !key.meta) { + const cleaned = stripBracketedPaste(input) + if (cleaned) { + setValue((prev) => prev + cleaned) + } + } + }) + + return ( + + + ? + {message} + {'*'.repeat(value.length)} + + + ) +} diff --git a/src/tui/features/vc/commit/components/vc-commit-flow-state.ts b/src/tui/features/vc/commit/components/vc-commit-flow-state.ts new file mode 100644 index 000000000..89df95b06 --- /dev/null +++ b/src/tui/features/vc/commit/components/vc-commit-flow-state.ts @@ -0,0 +1,83 @@ +/** + * Pure state machine for the TUI commit flow. + * + * Split out of vc-commit-flow.tsx so the transitions are unit-testable + * without spinning up an Ink tree. The component owns side effects + * (firing mutations, rendering Ink nodes); this module owns decisions. + * + * Required by ENG-2002 ยงSigning Flow step 2: encrypted-key passphrase + * prompting must live in the TUI (not oclif). + */ + +import {VcErrorCode} from '../../../../../shared/transport/events/vc-events.js' +import {formatTransportError, getTransportErrorCode} from '../../../../utils/error-messages.js' + +/** Cap matches the retry limit the pre-B1 oclif command used to enforce. */ +export const MAX_PASSPHRASE_RETRIES = 3 + +export type CommitFlowState = + | {attempt: number; kind: 'awaiting-passphrase'} + | {attempt: number; kind: 'committing'} + | {kind: 'done'; message: string; outcome: 'cancelled' | 'error' | 'success'} + +export type CommitFlowEvent = + | {error: unknown; type: 'commit-error'} + | {message: string; sha: string; signed?: boolean; type: 'commit-success'} + | {type: 'passphrase-cancelled'} + | {type: 'passphrase-submitted'} + +export const initialCommitFlowState: CommitFlowState = {attempt: 0, kind: 'committing'} + +export function reduceCommitFlow( + state: CommitFlowState, + event: CommitFlowEvent, +): CommitFlowState { + if (state.kind === 'done') return state + + switch (event.type) { + case 'commit-error': { + const code = getTransportErrorCode(event.error) + if (code === VcErrorCode.PASSPHRASE_REQUIRED && state.kind === 'committing') { + // Daemon re-rejected after a passphrase retry: cap the loop. + if (state.attempt >= MAX_PASSPHRASE_RETRIES) { + return { + kind: 'done', + message: `Too many failed passphrase attempts (${MAX_PASSPHRASE_RETRIES}).`, + outcome: 'error', + } + } + + return {attempt: state.attempt + 1, kind: 'awaiting-passphrase'} + } + + return { + kind: 'done', + message: `Failed to commit: ${formatTransportError(event.error)}`, + outcome: 'error', + } + } + + case 'commit-success': { + const signed = event.signed ? ' ๐Ÿ”' : '' + return { + kind: 'done', + message: `[${event.sha.slice(0, 7)}] ${event.message}${signed}`, + outcome: 'success', + } + } + + case 'passphrase-cancelled': { + if (state.kind !== 'awaiting-passphrase') return state + return { + kind: 'done', + message: 'Passphrase entry cancelled.', + outcome: 'cancelled', + } + } + + case 'passphrase-submitted': { + if (state.kind !== 'awaiting-passphrase') return state + return {attempt: state.attempt, kind: 'committing'} + } + } +} diff --git a/src/tui/features/vc/commit/components/vc-commit-flow.tsx b/src/tui/features/vc/commit/components/vc-commit-flow.tsx index 69aabca7c..83e3029f9 100644 --- a/src/tui/features/vc/commit/components/vc-commit-flow.tsx +++ b/src/tui/features/vc/commit/components/vc-commit-flow.tsx @@ -1,11 +1,12 @@ import {Text, useInput} from 'ink' import Spinner from 'ink-spinner' -import React, {useEffect} from 'react' +import React, {useCallback, useEffect, useReducer, useRef} from 'react' import type {CustomDialogCallbacks} from '../../../../types/commands.js' -import {formatTransportError} from '../../../../utils/error-messages.js' +import {InlinePassword} from '../../../../components/inline-prompts/index.js' import {useExecuteVcCommit} from '../api/execute-vc-commit.js' +import {initialCommitFlowState, reduceCommitFlow} from './vc-commit-flow-state.js' type VcCommitFlowProps = CustomDialogCallbacks & { message: string @@ -13,33 +14,82 @@ type VcCommitFlowProps = CustomDialogCallbacks & { export function VcCommitFlow({message, onCancel, onComplete}: VcCommitFlowProps): React.ReactNode { const commitMutation = useExecuteVcCommit() + const [state, dispatch] = useReducer(reduceCommitFlow, initialCommitFlowState) + // Escape aborts only while committing (not mid-passphrase-entry โ€” the input + // component owns Escape there so the user can cancel the prompt). useInput((_, key) => { - if (key.escape && !commitMutation.isPending) { + if (key.escape && state.kind === 'committing' && !commitMutation.isPending) { onCancel() } }) - const fired = React.useRef(false) + // Remember passphrase across the synchronous `passphrase-submitted` โ†’ + // `committing` transition so the mutation fires with it. + const passphraseRef = useRef(undefined) + + const fireCommit = useCallback( + (passphrase?: string) => { + commitMutation.mutate( + passphrase === undefined ? {message} : {message, passphrase}, + { + onError(error) { + dispatch({error, type: 'commit-error'}) + }, + onSuccess(result) { + dispatch({ + message: result.message, + sha: result.sha, + type: 'commit-success', + ...(result.signed ? {signed: true} : {}), + }) + }, + }, + ) + }, + [commitMutation, message], + ) + + // First commit attempt + const fired = useRef(false) useEffect(() => { if (fired.current) return fired.current = true - commitMutation.mutate( - {message}, - { - onError(error) { - onComplete(`Failed to commit: ${formatTransportError(error)}`) - }, - onSuccess(result) { - onComplete(`[${result.sha.slice(0, 7)}] ${result.message}`) - }, - }, + fireCommit() + }, [fireCommit]) + + // Terminal state โ†’ bubble up to dialog manager + useEffect(() => { + if (state.kind === 'done') { + onComplete(state.message) + } + }, [state, onComplete]) + + if (state.kind === 'awaiting-passphrase') { + return ( + dispatch({type: 'passphrase-cancelled'})} + onSubmit={(pp) => { + passphraseRef.current = pp + dispatch({type: 'passphrase-submitted'}) + fireCommit(pp) + }} + /> ) - }, []) + } - return ( - - Committing... - - ) + if (state.kind === 'committing') { + return ( + + Committing... + + ) + } + + // Terminal โ€” useEffect above bubbles the outcome; render nothing + return null } + +// Re-export so consumers (e.g., tests) can inspect the underlying state shape. +export type {CommitFlowState} from './vc-commit-flow-state.js' diff --git a/test/unit/tui/features/vc/commit/vc-commit-flow-state.test.ts b/test/unit/tui/features/vc/commit/vc-commit-flow-state.test.ts new file mode 100644 index 000000000..f1b85c01a --- /dev/null +++ b/test/unit/tui/features/vc/commit/vc-commit-flow-state.test.ts @@ -0,0 +1,148 @@ +import {expect} from 'chai' + +import {VcErrorCode} from '../../../../../../src/shared/transport/events/vc-events.js' +import { + type CommitFlowState, + initialCommitFlowState, + MAX_PASSPHRASE_RETRIES, + reduceCommitFlow, +} from '../../../../../../src/tui/features/vc/commit/components/vc-commit-flow-state.js' + +function errorWithCode(code: string): Error & {code: string} { + const err = new Error('Simulated transport error') as Error & {code: string} + err.code = code + return err +} + +describe('reduceCommitFlow()', () => { + describe('from initial (committing, attempt 0)', () => { + it('commit-success โ†’ done(success) with formatted SHA + message + unsigned tag', () => { + const result = reduceCommitFlow(initialCommitFlowState, { + message: 'hello', + sha: 'abcdef1234567890', + type: 'commit-success', + }) + + expect(result).to.deep.equal({ + kind: 'done', + message: '[abcdef1] hello', + outcome: 'success', + }) + }) + + it('commit-success with signed:true includes the signing indicator', () => { + const result = reduceCommitFlow(initialCommitFlowState, { + message: 'signed msg', + sha: 'deadbeefcafe', + signed: true, + type: 'commit-success', + }) + + expect(result.kind).to.equal('done') + if (result.kind !== 'done') throw new Error('unreachable') + expect(result.message).to.equal('[deadbee] signed msg ๐Ÿ”') + }) + + it('commit-error with PASSPHRASE_REQUIRED โ†’ awaiting-passphrase(attempt=1)', () => { + const result = reduceCommitFlow(initialCommitFlowState, { + error: errorWithCode(VcErrorCode.PASSPHRASE_REQUIRED), + type: 'commit-error', + }) + + expect(result).to.deep.equal({attempt: 1, kind: 'awaiting-passphrase'}) + }) + + it('commit-error with a non-passphrase code โ†’ done(error)', () => { + const result = reduceCommitFlow(initialCommitFlowState, { + error: errorWithCode('SOMETHING_ELSE'), + type: 'commit-error', + }) + + expect(result.kind).to.equal('done') + if (result.kind !== 'done') throw new Error('unreachable') + expect(result.outcome).to.equal('error') + expect(result.message).to.match(/Failed to commit/) + }) + }) + + describe('from awaiting-passphrase', () => { + const awaiting: CommitFlowState = {attempt: 1, kind: 'awaiting-passphrase'} + + it('passphrase-submitted โ†’ committing (attempt preserved)', () => { + const result = reduceCommitFlow(awaiting, {type: 'passphrase-submitted'}) + expect(result).to.deep.equal({attempt: 1, kind: 'committing'}) + }) + + it('passphrase-cancelled โ†’ done(cancelled)', () => { + const result = reduceCommitFlow(awaiting, {type: 'passphrase-cancelled'}) + expect(result).to.deep.equal({ + kind: 'done', + message: 'Passphrase entry cancelled.', + outcome: 'cancelled', + }) + }) + }) + + describe('retry cap', () => { + it('PASSPHRASE_REQUIRED after MAX_PASSPHRASE_RETRIES attempts โ†’ done(error, "Too many failedโ€ฆ")', () => { + const atCap: CommitFlowState = {attempt: MAX_PASSPHRASE_RETRIES, kind: 'committing'} + const result = reduceCommitFlow(atCap, { + error: errorWithCode(VcErrorCode.PASSPHRASE_REQUIRED), + type: 'commit-error', + }) + + expect(result.kind).to.equal('done') + if (result.kind !== 'done') throw new Error('unreachable') + expect(result.outcome).to.equal('error') + expect(result.message).to.match(/Too many failed passphrase attempts/) + expect(result.message).to.include(String(MAX_PASSPHRASE_RETRIES)) + }) + + it('PASSPHRASE_REQUIRED at attempt < MAX โ†’ back to awaiting-passphrase (attempt+1)', () => { + const belowCap: CommitFlowState = {attempt: 1, kind: 'committing'} + const result = reduceCommitFlow(belowCap, { + error: errorWithCode(VcErrorCode.PASSPHRASE_REQUIRED), + type: 'commit-error', + }) + + expect(result).to.deep.equal({attempt: 2, kind: 'awaiting-passphrase'}) + }) + }) + + describe('terminal state is absorbing', () => { + const done: CommitFlowState = {kind: 'done', message: 'x', outcome: 'success'} + + it('ignores commit-success once done', () => { + const result = reduceCommitFlow(done, { + message: 'new', + sha: '1234567890', + type: 'commit-success', + }) + expect(result).to.equal(done) + }) + + it('ignores commit-error once done', () => { + const result = reduceCommitFlow(done, {error: new Error('x'), type: 'commit-error'}) + expect(result).to.equal(done) + }) + + it('ignores passphrase events once done', () => { + expect(reduceCommitFlow(done, {type: 'passphrase-submitted'})).to.equal(done) + expect(reduceCommitFlow(done, {type: 'passphrase-cancelled'})).to.equal(done) + }) + }) + + describe('out-of-order events are no-ops', () => { + it('passphrase-submitted while committing โ†’ unchanged', () => { + const committing: CommitFlowState = {attempt: 0, kind: 'committing'} + const result = reduceCommitFlow(committing, {type: 'passphrase-submitted'}) + expect(result).to.equal(committing) + }) + + it('passphrase-cancelled while committing โ†’ unchanged', () => { + const committing: CommitFlowState = {attempt: 0, kind: 'committing'} + const result = reduceCommitFlow(committing, {type: 'passphrase-cancelled'}) + expect(result).to.equal(committing) + }) + }) +}) From 8feaa2c68c8bda066f285d67b1cf48bf3dc6d3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 00:41:09 +0700 Subject: [PATCH 24/46] refactor(vc): drop camel-case `user.signingKey` from exported union (ENG-2002 M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VcConfigKey type used to include both `'user.signingkey'` and `'user.signingKey'`, and VC_CONFIG_KEYS listed them both โ€” a duplicate masquerading as two valid keys. The `git config` CLI convention is all lowercase, the internal FIELD_MAP lookup already normalises via toLowerCase(), and the file store always writes a single canonical `signingKey` field. The dual spelling was surface noise. Canonicalise on lowercase. isVcConfigKey() keeps the case-insensitive parse so legacy camel-case callers still work at runtime, but new code cannot depend on the variant via the type. Regression test covers lowercase / camel / upper inputs and rejects unrelated keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/transport/events/vc-events.ts | 9 +++-- .../shared/transport/events/vc-events.test.ts | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 test/unit/shared/transport/events/vc-events.test.ts diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index e3536288a..5fdf20540 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -106,18 +106,21 @@ export interface IVcCommitResponse { signed?: boolean } -export type VcConfigKey = 'commit.sign' | 'user.email' | 'user.name' | 'user.signingkey' | 'user.signingKey' +// Canonical form is all-lowercase (matches the `git config` CLI convention and +// the FIELD_MAP lookup key in vc-handler.ts). Camel-case variants from legacy +// callers are accepted at runtime via case-insensitive lookup below, but are +// not part of the exported union so new code cannot depend on them. +export type VcConfigKey = 'commit.sign' | 'user.email' | 'user.name' | 'user.signingkey' export const VC_CONFIG_KEYS: readonly string[] = [ 'user.name', 'user.email', 'user.signingkey', - 'user.signingKey', 'commit.sign', ] satisfies readonly VcConfigKey[] export function isVcConfigKey(key: string): key is VcConfigKey { - return (VC_CONFIG_KEYS as readonly string[]).includes(key) || (VC_CONFIG_KEYS as readonly string[]).includes(key.toLowerCase()) + return (VC_CONFIG_KEYS as readonly string[]).includes(key.toLowerCase()) } /** Import SSH signing settings from local/global git config (no key/value needed). */ diff --git a/test/unit/shared/transport/events/vc-events.test.ts b/test/unit/shared/transport/events/vc-events.test.ts new file mode 100644 index 000000000..d5c7a109e --- /dev/null +++ b/test/unit/shared/transport/events/vc-events.test.ts @@ -0,0 +1,38 @@ +import {expect} from 'chai' + +import {isVcConfigKey, VC_CONFIG_KEYS} from '../../../../../src/shared/transport/events/vc-events.js' + +describe('VcConfigKey', () => { + it('canonical keys are all lowercase (no dual camel-case entries)', () => { + // M4: camel-case variants used to be part of the exported set and caused + // duplicate entries in VC_CONFIG_KEYS. Canonical is lowercase only. + expect(VC_CONFIG_KEYS).to.deep.equal([ + 'user.name', + 'user.email', + 'user.signingkey', + 'commit.sign', + ]) + }) + + describe('isVcConfigKey() โ€” lenient case-insensitive parse', () => { + it('accepts the canonical lowercase form', () => { + expect(isVcConfigKey('user.signingkey')).to.be.true + }) + + it('accepts a camel-case variant (backward compat for git-style spelling)', () => { + expect(isVcConfigKey('user.signingKey')).to.be.true + }) + + it('accepts an all-uppercase variant', () => { + expect(isVcConfigKey('USER.SIGNINGKEY')).to.be.true + }) + + it('rejects an unrelated key', () => { + expect(isVcConfigKey('user.notARealKey')).to.be.false + }) + + it('rejects the empty string', () => { + expect(isVcConfigKey('')).to.be.false + }) + }) +}) From 0b4f7a5202829f2713f05e67bf377c2e5ca6c3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 00:45:46 +0700 Subject: [PATCH 25/46] feat(signing-key): require --yes to confirm destructive remove (ENG-2002 M5) `brv signing-key remove ` previously executed the remote IAM delete immediately, with no safeguard. A typo in the ID (or a stale key from `brv signing-key list` output) silently destroyed the wrong credential. Add a mandatory --yes flag. Per Bao's oclif non-interactive rule, the command cannot prompt; instead, without --yes the command exits 2 with: Refusing to remove signing key '' without explicit --yes confirmation. This action is irreversible. Re-run with --yes to proceed. Users who really mean it pass --yes. The help text declares the flag. Test stubs this.error and asserts the guard fires before any network call, plus confirms the flag is declared on the command. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/oclif/commands/signing-key/remove.ts | 21 +++++++++--- test/commands/signing-key/remove.test.ts | 41 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 test/commands/signing-key/remove.test.ts diff --git a/src/oclif/commands/signing-key/remove.ts b/src/oclif/commands/signing-key/remove.ts index 6c191c633..3e58dda9f 100644 --- a/src/oclif/commands/signing-key/remove.ts +++ b/src/oclif/commands/signing-key/remove.ts @@ -1,4 +1,4 @@ -import {Args, Command} from '@oclif/core' +import {Args, Command, Flags} from '@oclif/core' import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' @@ -11,13 +11,26 @@ export default class SigningKeyRemove extends Command { }), } public static description = 'Remove an SSH signing key from your Byterover account' -public static examples = [ - '<%= config.bin %> <%= command.id %> ', + public static examples = [ + '<%= config.bin %> <%= command.id %> --yes', '# Get key ID from: brv signing-key list', ] + public static flags = { + yes: Flags.boolean({ + default: false, + description: 'Confirm the destructive removal (required โ€” oclif commands never prompt).', + }), + } public async run(): Promise { - const {args} = await this.parse(SigningKeyRemove) + const {args, flags} = await this.parse(SigningKeyRemove) + + if (!flags.yes) { + this.error( + `Refusing to remove signing key '${args.id}' without explicit --yes confirmation. ` + + 'This action is irreversible. Re-run with --yes to proceed.', + ) + } try { await withDaemonRetry(async (client) => diff --git a/test/commands/signing-key/remove.test.ts b/test/commands/signing-key/remove.test.ts new file mode 100644 index 000000000..9eda0b92f --- /dev/null +++ b/test/commands/signing-key/remove.test.ts @@ -0,0 +1,41 @@ +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import SigningKeyRemove from '../../../src/oclif/commands/signing-key/remove.js' + +describe('signing-key remove --yes guard', () => { + let config: Config + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + afterEach(() => { + restore() + }) + + it('refuses without --yes and does not touch the network', async () => { + const cmd = new SigningKeyRemove(['fake-id'], config) + const errorStub = stub(cmd, 'error').throws(new Error('STOP')) + + let thrown: unknown + try { + await cmd.run() + } catch (error) { + thrown = error + } + + expect(thrown).to.be.instanceOf(Error) + expect(errorStub.calledOnce).to.be.true + const [firstArg] = errorStub.firstCall.args as [string] + expect(firstArg).to.match(/--yes/) + expect(firstArg).to.match(/irreversible/i) + }) + + it('declares the --yes flag on the command', () => { + expect(SigningKeyRemove.flags).to.have.property('yes') + }) +}) From ce82e1773b18b546b66c8837d1e412787360b9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 00:55:23 +0700 Subject: [PATCH 26/46] refactor(ssh): scope SigningKeyCache entries by (projectPath, keyPath) (ENG-2002 M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache previously keyed solely on the resolved signing-key file path. Two projects on the same machine whose users happened to configure the same keyPath would share one decrypted KeyObject โ€” switching between them silently inherited credentials across project boundaries. Change the API to require projectPath alongside keyPath on get/set/ invalidate. Entries are composed internally via `${project}\0${key}`; the null byte cannot appear in POSIX or Windows path components, so the separator is collision-free without escaping. Update the four call sites in vc-handler.ts (resolveSigningKey, handleCommit path, handleConfig signingKey hint, handleImportGitSigning) to thread the projectPath already in scope. Tests: new "project isolation" describe block exercises same keyPath across two projects. Full cache suite migrated to the two-arg API. vc-handler regression suite still passes (205 tests). Note on `KeyObject.destroy()`: Bao's original review item also asked for KeyObject.destroy() on TTL eviction. Node's crypto.KeyObject has no such API โ€” it is GC-managed. Dropping the Map entry is the only cleanup hook available. That part of M2 is a no-op by API constraint, noted here for the record. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/signing-key-cache.ts | 38 ++++-- .../infra/transport/handlers/vc-handler.ts | 19 ++- test/unit/infra/ssh/signing-key-cache.test.ts | 120 +++++++++++------- 3 files changed, 111 insertions(+), 66 deletions(-) diff --git a/src/server/infra/ssh/signing-key-cache.ts b/src/server/infra/ssh/signing-key-cache.ts index 5c0aaa765..ef20545e2 100644 --- a/src/server/infra/ssh/signing-key-cache.ts +++ b/src/server/infra/ssh/signing-key-cache.ts @@ -12,16 +12,22 @@ interface CacheEntry { * cache the ParsedSSHKey object (which holds an opaque crypto.KeyObject) * so subsequent commits within the TTL window require no passphrase prompt. * + * Scoping: entries are keyed by (projectPath, keyPath). Two different projects + * that happen to share an identical signing-key path MUST NOT share cached + * state โ€” a user switching projects should not inherit the previous project's + * decrypted key object. The null byte separator is safe because POSIX and + * Windows paths cannot contain \0. + * * Security properties: * - Stored in daemon process memory only โ€” never written to disk * - crypto.KeyObject is opaque and not directly extractable * - Passphrase is never stored โ€” only the decrypted key object * - Cleared entirely on daemon restart - * - Per-key invalidation when user changes config + * - Per-project, per-key invalidation when user changes config */ export class SigningKeyCache { private readonly cache = new Map() - private readonly ttlMs: number +private readonly ttlMs: number /** * @param ttlMs - Cache TTL in milliseconds. @@ -35,6 +41,11 @@ export class SigningKeyCache { this.ttlMs = ttlMs } + /** Null byte cannot appear in POSIX or Windows path components. */ + private static composeKey(projectPath: string, keyPath: string): string { + return `${projectPath}\0${keyPath}` + } + /** * Current number of cached (non-expired) keys. */ @@ -49,15 +60,16 @@ export class SigningKeyCache { } /** - * Get a cached key by its resolved file path. + * Get a cached key for a (project, key) pair. * Returns null if the entry does not exist or has expired. */ - get(keyPath: string): null | ParsedSSHKey { - const entry = this.cache.get(keyPath) + get(projectPath: string, keyPath: string): null | ParsedSSHKey { + const compositeKey = SigningKeyCache.composeKey(projectPath, keyPath) + const entry = this.cache.get(compositeKey) if (!entry) return null if (Date.now() > entry.expiresAt) { - this.cache.delete(keyPath) + this.cache.delete(compositeKey) return null } @@ -65,10 +77,10 @@ export class SigningKeyCache { } /** - * Invalidate a specific key (e.g., when user changes signing key config). + * Invalidate a specific (project, key) pair (e.g., when user changes config). */ - invalidate(keyPath: string): void { - this.cache.delete(keyPath) + invalidate(projectPath: string, keyPath: string): void { + this.cache.delete(SigningKeyCache.composeKey(projectPath, keyPath)) } /** @@ -79,11 +91,11 @@ export class SigningKeyCache { } /** - * Cache a parsed key by its resolved file path. - * Resets TTL if the key was already cached. + * Cache a parsed key for a (project, key) pair. + * Resets TTL if the entry was already cached. * Also sweeps expired entries to prevent memory leaks. */ - set(keyPath: string, key: ParsedSSHKey): void { + set(projectPath: string, keyPath: string, key: ParsedSSHKey): void { const now = Date.now() // Sweep expired entries @@ -91,7 +103,7 @@ export class SigningKeyCache { if (now > entry.expiresAt) this.cache.delete(path) } - this.cache.set(keyPath, { + this.cache.set(SigningKeyCache.composeKey(projectPath, keyPath), { expiresAt: now + this.ttlMs, key, }) diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 8dba95b5f..b0ee22f15 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -575,7 +575,7 @@ export class VcHandler { } } else { // Path B/C: cache or file-based parsing - const parsed = await this.resolveSigningKey(keyPath, data.passphrase) + const parsed = await this.resolveSigningKey(projectPath, keyPath, data.passphrase) onSign = async (payload: string) => { const result = signCommitPayload(payload, parsed) return result.armored @@ -655,7 +655,7 @@ export class VcHandler { try { const parsed = await parseSSHPrivateKey(resolvedPath) // Cache the parsed key for immediate use - this.signingKeyCache.set(resolvedPath, parsed) + this.signingKeyCache.set(projectPath, resolvedPath, parsed) hint = `Fingerprint: ${parsed.fingerprint}` } catch { // Encrypted key โ€” require passphrase to get fingerprint; skip hint @@ -776,7 +776,7 @@ export class VcHandler { let hint: string | undefined try { const parsed = await parseSSHPrivateKey(resolvedPath) - this.signingKeyCache.set(resolvedPath, parsed) + this.signingKeyCache.set(projectPath, resolvedPath, parsed) hint = `Fingerprint: ${parsed.fingerprint} | commit.sign: ${String(commitSign)}` } catch { hint = `commit.sign: ${String(commitSign)}` @@ -1477,9 +1477,14 @@ export class VcHandler { * Path B: in-memory TTL cache (passphrase not needed after first use) * Path C: parse key file (may require passphrase โ€” delegates to CLI caller via PASSPHRASE_REQUIRED error) */ - private async resolveSigningKey(keyPath: string, passphrase?: string): Promise { - // Path B: in-memory TTL cache - const cachedKey = this.signingKeyCache.get(keyPath) + private async resolveSigningKey( + projectPath: string, + keyPath: string, + passphrase?: string, + ): Promise { + // Path B: in-memory TTL cache โ€” scoped by (project, key) pair so a user + // switching projects never inherits a decrypted KeyObject from elsewhere. + const cachedKey = this.signingKeyCache.get(projectPath, keyPath) if (cachedKey) return cachedKey // Path C: parse key file @@ -1512,7 +1517,7 @@ export class VcHandler { const parsed = await parseSSHPrivateKey(keyPath, passphrase) // Store in cache (passphrase is not stored โ€” only the decrypted KeyObject) - this.signingKeyCache.set(keyPath, parsed) + this.signingKeyCache.set(projectPath, keyPath, parsed) return parsed } diff --git a/test/unit/infra/ssh/signing-key-cache.test.ts b/test/unit/infra/ssh/signing-key-cache.test.ts index d5fc087e0..77590faff 100644 --- a/test/unit/infra/ssh/signing-key-cache.test.ts +++ b/test/unit/infra/ssh/signing-key-cache.test.ts @@ -14,37 +14,72 @@ function makeFakeKey(id: string): ParsedSSHKey { } } +const P1 = '/projects/alpha' +const P2 = '/projects/beta' + describe('SigningKeyCache', () => { describe('get() / set()', () => { it('returns null for unknown key path', () => { const cache = new SigningKeyCache() - expect(cache.get('/nonexistent/key')).to.be.null + expect(cache.get(P1, '/nonexistent/key')).to.be.null }) it('returns stored key immediately after set()', () => { const cache = new SigningKeyCache() const key = makeFakeKey('abc') - cache.set('/home/user/.ssh/id_ed25519', key) - expect(cache.get('/home/user/.ssh/id_ed25519')).to.equal(key) + cache.set(P1, '/home/user/.ssh/id_ed25519', key) + expect(cache.get(P1, '/home/user/.ssh/id_ed25519')).to.equal(key) }) - it('different paths are stored independently', () => { + it('different paths within the same project are stored independently', () => { const cache = new SigningKeyCache() const key1 = makeFakeKey('k1') const key2 = makeFakeKey('k2') - cache.set('/path/to/key1', key1) - cache.set('/path/to/key2', key2) - expect(cache.get('/path/to/key1')).to.equal(key1) - expect(cache.get('/path/to/key2')).to.equal(key2) + cache.set(P1, '/path/to/key1', key1) + cache.set(P1, '/path/to/key2', key2) + expect(cache.get(P1, '/path/to/key1')).to.equal(key1) + expect(cache.get(P1, '/path/to/key2')).to.equal(key2) }) - it('overwriting a key replaces it', () => { + it('overwriting a (project, key) pair replaces the entry', () => { const cache = new SigningKeyCache() const key1 = makeFakeKey('v1') const key2 = makeFakeKey('v2') - cache.set('/same/path', key1) - cache.set('/same/path', key2) - expect(cache.get('/same/path')).to.equal(key2) + cache.set(P1, '/same/path', key1) + cache.set(P1, '/same/path', key2) + expect(cache.get(P1, '/same/path')).to.equal(key2) + }) + }) + + describe('project isolation (ENG-2002 M2)', () => { + it('same keyPath across two projects does NOT share cached entries', () => { + const cache = new SigningKeyCache() + const keyInAlpha = makeFakeKey('alpha') + const keyInBeta = makeFakeKey('beta') + cache.set(P1, '/home/user/.ssh/id_ed25519', keyInAlpha) + cache.set(P2, '/home/user/.ssh/id_ed25519', keyInBeta) + + expect(cache.get(P1, '/home/user/.ssh/id_ed25519')).to.equal(keyInAlpha) + expect(cache.get(P2, '/home/user/.ssh/id_ed25519')).to.equal(keyInBeta) + }) + + it('get() from a different project returns null even with identical keyPath', () => { + const cache = new SigningKeyCache() + cache.set(P1, '/shared/path', makeFakeKey('p1')) + expect(cache.get(P2, '/shared/path')).to.be.null + }) + + it('invalidate() is project-scoped', () => { + const cache = new SigningKeyCache() + const keyInAlpha = makeFakeKey('alpha') + const keyInBeta = makeFakeKey('beta') + cache.set(P1, '/shared/path', keyInAlpha) + cache.set(P2, '/shared/path', keyInBeta) + + cache.invalidate(P1, '/shared/path') + + expect(cache.get(P1, '/shared/path')).to.be.null + expect(cache.get(P2, '/shared/path')).to.equal(keyInBeta) }) }) @@ -54,10 +89,10 @@ describe('SigningKeyCache', () => { expect(cache.size).to.equal(0) }) - it('counts non-expired entries', () => { + it('counts non-expired entries across projects', () => { const cache = new SigningKeyCache() - cache.set('/a', makeFakeKey('a')) - cache.set('/b', makeFakeKey('b')) + cache.set(P1, '/a', makeFakeKey('a')) + cache.set(P2, '/a', makeFakeKey('b')) expect(cache.size).to.equal(2) }) }) @@ -66,18 +101,15 @@ describe('SigningKeyCache', () => { it('returns null after TTL expires', async function () { this.timeout(3000) - // Create cache with 50ms TTL for fast test const cache = new SigningKeyCache(50) const key = makeFakeKey('ttl-test') - cache.set('/ttl/test', key) + cache.set(P1, '/ttl/test', key) - // Still accessible immediately - expect(cache.get('/ttl/test')).to.equal(key) + expect(cache.get(P1, '/ttl/test')).to.equal(key) - // Wait for TTL to expire await new Promise((resolve) => { setTimeout(resolve, 100) }) - expect(cache.get('/ttl/test')).to.be.null + expect(cache.get(P1, '/ttl/test')).to.be.null }) it('returns key before TTL expires', async function () { @@ -85,11 +117,11 @@ describe('SigningKeyCache', () => { const cache = new SigningKeyCache(500) const key = makeFakeKey('ttl-alive') - cache.set('/ttl/alive', key) + cache.set(P1, '/ttl/alive', key) await new Promise((resolve) => { setTimeout(resolve, 50) }) - expect(cache.get('/ttl/alive')).to.equal(key) + expect(cache.get(P1, '/ttl/alive')).to.equal(key) }) }) @@ -98,53 +130,49 @@ describe('SigningKeyCache', () => { this.timeout(3000) const cache = new SigningKeyCache(50) - cache.set('/old', makeFakeKey('old')) + cache.set(P1, '/old', makeFakeKey('old')) - // Wait for TTL to expire await new Promise((resolve) => { setTimeout(resolve, 100) }) - // Set a new key โ€” should sweep the expired '/old' entry - cache.set('/new', makeFakeKey('new')) + cache.set(P1, '/new', makeFakeKey('new')) - // The expired entry should have been cleaned up from internal storage - // size only counts non-expired, so it should be 1 expect(cache.size).to.equal(1) - expect(cache.get('/old')).to.be.null - expect(cache.get('/new')).to.not.be.null + expect(cache.get(P1, '/old')).to.be.null + expect(cache.get(P1, '/new')).to.not.be.null }) }) describe('invalidate()', () => { - it('removes a specific key path from cache', () => { + it('removes a specific (project, key) pair from cache', () => { const cache = new SigningKeyCache() const key = makeFakeKey('to-clear') - cache.set('/invalidate/me', key) - cache.invalidate('/invalidate/me') - expect(cache.get('/invalidate/me')).to.be.null + cache.set(P1, '/invalidate/me', key) + cache.invalidate(P1, '/invalidate/me') + expect(cache.get(P1, '/invalidate/me')).to.be.null }) it('does not affect other cached keys when invalidating one', () => { const cache = new SigningKeyCache() const key1 = makeFakeKey('keep') const key2 = makeFakeKey('remove') - cache.set('/keep', key1) - cache.set('/remove', key2) - cache.invalidate('/remove') - expect(cache.get('/keep')).to.equal(key1) - expect(cache.get('/remove')).to.be.null + cache.set(P1, '/keep', key1) + cache.set(P1, '/remove', key2) + cache.invalidate(P1, '/remove') + expect(cache.get(P1, '/keep')).to.equal(key1) + expect(cache.get(P1, '/remove')).to.be.null }) }) describe('invalidateAll()', () => { - it('clears all entries', () => { + it('clears all entries across all projects', () => { const cache = new SigningKeyCache() - cache.set('/a', makeFakeKey('a')) - cache.set('/b', makeFakeKey('b')) - cache.set('/c', makeFakeKey('c')) + cache.set(P1, '/a', makeFakeKey('a')) + cache.set(P1, '/b', makeFakeKey('b')) + cache.set(P2, '/c', makeFakeKey('c')) cache.invalidateAll() expect(cache.size).to.equal(0) - expect(cache.get('/a')).to.be.null - expect(cache.get('/b')).to.be.null + expect(cache.get(P1, '/a')).to.be.null + expect(cache.get(P2, '/c')).to.be.null }) }) }) From 2e4d228b5666c134a0c106800dde424433740be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 00:59:27 +0700 Subject: [PATCH 27/46] feat(ssh): add scrubPassphrase helper for safe payload logging (ENG-2002 M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No current server-side code path logs or stringifies an IVcCommitRequest โ€” audit of src/server/infra turned up zero active leak sites. M3 is therefore a defensive primitive: anyone adding telemetry, error serialisation, or debug output touching a commit request in future should import scrubPassphrase() instead of handling the secret ad-hoc. The helper is generic over any object shape with an optional `passphrase: string` field, returns the original reference when there is nothing to redact (cheap common case), never mutates its input, and replaces a non-empty secret with literal "***". Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/scrub-passphrase.ts | 16 +++++++ test/unit/shared/ssh/scrub-passphrase.test.ts | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/shared/ssh/scrub-passphrase.ts create mode 100644 test/unit/shared/ssh/scrub-passphrase.test.ts diff --git a/src/shared/ssh/scrub-passphrase.ts b/src/shared/ssh/scrub-passphrase.ts new file mode 100644 index 000000000..292d27f6c --- /dev/null +++ b/src/shared/ssh/scrub-passphrase.ts @@ -0,0 +1,16 @@ +/** + * Redact a `passphrase` field from an object while preserving the rest. + * + * Use this wherever a request/response payload that may carry a passphrase + * is about to be logged, serialised into an error, stringified for telemetry, + * or echoed back to a client: never let the raw secret leave the in-memory + * handler that consumes it. + * + * Generic over any object that has an optional `passphrase: string` โ€” which + * today is `IVcCommitRequest` (shared/transport/events/vc-events.ts) but may + * grow in the future. + */ +export function scrubPassphrase(payload: T): T { + if (payload.passphrase === undefined || payload.passphrase === '') return payload + return {...payload, passphrase: '***'} +} diff --git a/test/unit/shared/ssh/scrub-passphrase.test.ts b/test/unit/shared/ssh/scrub-passphrase.test.ts new file mode 100644 index 000000000..41a5312ed --- /dev/null +++ b/test/unit/shared/ssh/scrub-passphrase.test.ts @@ -0,0 +1,43 @@ +import {expect} from 'chai' + +import {scrubPassphrase} from '../../../../src/shared/ssh/scrub-passphrase.js' + +describe('scrubPassphrase()', () => { + it('redacts a non-empty passphrase to "***"', () => { + const input = {message: 'hi', passphrase: 'supersecret'} + const output = scrubPassphrase(input) + expect(output.passphrase).to.equal('***') + expect(output.message).to.equal('hi') + }) + + it('preserves all other fields unchanged', () => { + const input = {message: 'msg', passphrase: 's', sign: true} + const output = scrubPassphrase(input) + expect(output).to.deep.equal({message: 'msg', passphrase: '***', sign: true}) + }) + + it('returns the same reference when passphrase is undefined', () => { + const input: {message: string; passphrase?: string} = {message: 'hi'} + const output = scrubPassphrase(input) + expect(output).to.equal(input) + }) + + it('returns the same reference when passphrase is the empty string', () => { + const input = {message: 'hi', passphrase: ''} + const output = scrubPassphrase(input) + expect(output).to.equal(input) + }) + + it('does not mutate the input object', () => { + const input = {message: 'hi', passphrase: 'secret'} + scrubPassphrase(input) + expect(input.passphrase).to.equal('secret') + }) + + it('works on shapes beyond IVcCommitRequest (generic)', () => { + const input = {passphrase: 'x', unrelated: [1, 2, 3]} + const output = scrubPassphrase(input) + expect(output.passphrase).to.equal('***') + expect(output.unrelated).to.deep.equal([1, 2, 3]) + }) +}) From 4adcbdcac30df6cb64b8572aa774fbb9458a6795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 01:03:04 +0700 Subject: [PATCH 28/46] refactor(ssh): consolidate SSHSIG_HASH_ALGORITHM constant + document rationale (ENG-2002 M6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both signers had `const HASH_ALGORITHM = 'sha512'` inlined, and bare `createHash('sha512')` / `sign('sha512', ...)` calls mirrored it. Bao flagged this as "sha512 hard-coded, no sha256 fallback". The hard-coding is intentional, not an oversight: - Ed25519 (primary supported key type) mandates SHA-512 โ€” the EdDSA algorithm has no other choice. - RSA via ssh-agent already uses the RSA-SHA2-512 flag per OpenSSH default. - Every OpenSSH verifier from 2017+ accepts sha512-labelled sshsig signatures; there is no consumer-compat benefit to offering sha256. Move the constant into sshsig-constants.ts alongside SSHSIG_MAGIC_PREAMBLE and spell out the three reasons inline, so the next reviewer does not ask the same "why is this hard-coded" question. Both signers import the shared constant; all four `'sha512'` string literals become references. If a future need arises, the doc explicitly tells the next developer to thread the constant through signCommitPayload and SshAgentSigner.sign as a parameter rather than duplicating the literal. No behavior change. 57 SSH tests + ssh-keygen round-trip still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/ssh-agent-signer.ts | 11 +++++------ src/server/infra/ssh/sshsig-constants.ts | 16 ++++++++++++++++ src/server/infra/ssh/sshsig-signer.ts | 13 ++++++------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index abc64d164..e90eb9e6b 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -4,7 +4,7 @@ import net from 'node:net' import type {SSHKeyType, SSHSignatureResult} from './types.js' import {getPublicKeyMetadata} from './ssh-key-parser.js' -import {SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' +import {SSHSIG_HASH_ALGORITHM, SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' const SSHSIG_MAGIC = Buffer.from(SSHSIG_MAGIC_PREAMBLE) @@ -180,17 +180,16 @@ export class SshAgentSigner { */ async sign(payload: string): Promise { const NAMESPACE = 'git' - const HASH_ALGORITHM = 'sha512' - // 1. Hash commit payload - const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() + // 1. Hash commit payload with the spec-mandated SHA-512. + const messageHash = createHash(SSHSIG_HASH_ALGORITHM).update(Buffer.from(payload, 'utf8')).digest() // 2. Build signed-data structure per PROTOCOL.sshsig ยง2 (6-byte SSHSIG preamble) const signedData = Buffer.concat([ SSHSIG_MAGIC, sshString(NAMESPACE), sshString(''), - sshString(HASH_ALGORITHM), + sshString(SSHSIG_HASH_ALGORITHM), sshString(messageHash), ]) @@ -214,7 +213,7 @@ export class SshAgentSigner { sshString(this.keyBlob), sshString(NAMESPACE), sshString(''), - sshString(HASH_ALGORITHM), + sshString(SSHSIG_HASH_ALGORITHM), sshString(agentSignature), ]) diff --git a/src/server/infra/ssh/sshsig-constants.ts b/src/server/infra/ssh/sshsig-constants.ts index 8c2bf783e..beb7726b9 100644 --- a/src/server/infra/ssh/sshsig-constants.ts +++ b/src/server/infra/ssh/sshsig-constants.ts @@ -9,3 +9,19 @@ // mutated across module boundaries โ€” Buffer is indexable-mutable. Each // importing module materialises its own module-private Buffer once at load. export const SSHSIG_MAGIC_PREAMBLE = 'SSHSIG' + +// Hash algorithm embedded in the sshsig signed-data structure. Fixed at +// `sha512`, not configurable, for three reasons: +// +// 1. Ed25519 (our primary supported key type) MANDATES SHA-512 as part of +// the EdDSA algorithm itself โ€” there is no other choice. +// 2. RSA signing via ssh-agent uses the `RSA-SHA2-512` agent flag +// (see ssh-agent-signer.ts). OpenSSH's default. +// 3. Every OpenSSH verifier from 2017 onwards accepts sha512-labelled +// sshsig signatures; there is no consumer-compat benefit to offering +// sha256 as an alternative. +// +// If a future key type or verifier needs sha256, thread this constant +// through signCommitPayload / SshAgentSigner.sign as a parameter โ€” do not +// re-hardcode in each signer. +export const SSHSIG_HASH_ALGORITHM = 'sha512' diff --git a/src/server/infra/ssh/sshsig-signer.ts b/src/server/infra/ssh/sshsig-signer.ts index 918eb7c1b..9ab794280 100644 --- a/src/server/infra/ssh/sshsig-signer.ts +++ b/src/server/infra/ssh/sshsig-signer.ts @@ -2,12 +2,11 @@ import {createHash, sign} from 'node:crypto' import type {ParsedSSHKey, SSHSignatureResult} from './types.js' -import {SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' +import {SSHSIG_HASH_ALGORITHM, SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' const SSHSIG_MAGIC = Buffer.from(SSHSIG_MAGIC_PREAMBLE) const SSHSIG_VERSION = 1 const NAMESPACE = 'git' -const HASH_ALGORITHM = 'sha512' /** * Encode a Buffer or string as an SSH wire-format length-prefixed string. @@ -30,9 +29,9 @@ function sshString(data: Buffer | string): Buffer { * @param key - Parsed SSH key from ssh-key-parser */ export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignatureResult { - // 1. Hash the commit payload with SHA-512 - // isomorphic-git passes payload as a string; convert to bytes first - const messageHash = createHash('sha512').update(Buffer.from(payload, 'utf8')).digest() + // 1. Hash the commit payload with the spec-mandated SHA-512. + // isomorphic-git passes payload as a string; convert to bytes first. + const messageHash = createHash(SSHSIG_HASH_ALGORITHM).update(Buffer.from(payload, 'utf8')).digest() // 2. Build the "signed data" structure per PROTOCOL.sshsig ยง2 // This is what the private key actually signs โ€” NOT the raw payload. @@ -40,7 +39,7 @@ export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignat SSHSIG_MAGIC, // 6-byte preamble per spec sshString(NAMESPACE), // "git" sshString(''), // reserved (empty) - sshString(HASH_ALGORITHM), // "sha512" + sshString(SSHSIG_HASH_ALGORITHM), // "sha512" sshString(messageHash), // H(payload) ]) @@ -68,7 +67,7 @@ export function signCommitPayload(payload: string, key: ParsedSSHKey): SSHSignat sshString(key.publicKeyBlob), // public key blob sshString(NAMESPACE), // "git" sshString(''), // reserved - sshString(HASH_ALGORITHM), // "sha512" + sshString(SSHSIG_HASH_ALGORITHM), // "sha512" sshString(signatureBlob), // wrapped signature ]) From db56ec0b2074f1739b21a765ed62534fc53b56c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 09:20:00 +0700 Subject: [PATCH 29/46] refactor(tui): drop dead passphraseRef in VcCommitFlow (ENG-2002 review A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ref was written on InlinePassword.onSubmit but never read โ€” the passphrase flows directly through fireCommit(pp) in the same synchronous handler, so there is no transition to bridge. Bao PR #435 Round 10 Minor A. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tui/features/vc/commit/components/vc-commit-flow.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/tui/features/vc/commit/components/vc-commit-flow.tsx b/src/tui/features/vc/commit/components/vc-commit-flow.tsx index 83e3029f9..805650649 100644 --- a/src/tui/features/vc/commit/components/vc-commit-flow.tsx +++ b/src/tui/features/vc/commit/components/vc-commit-flow.tsx @@ -24,10 +24,6 @@ export function VcCommitFlow({message, onCancel, onComplete}: VcCommitFlowProps) } }) - // Remember passphrase across the synchronous `passphrase-submitted` โ†’ - // `committing` transition so the mutation fires with it. - const passphraseRef = useRef(undefined) - const fireCommit = useCallback( (passphrase?: string) => { commitMutation.mutate( @@ -71,7 +67,6 @@ export function VcCommitFlow({message, onCancel, onComplete}: VcCommitFlowProps) message="Enter SSH key passphrase:" onCancel={() => dispatch({type: 'passphrase-cancelled'})} onSubmit={(pp) => { - passphraseRef.current = pp dispatch({type: 'passphrase-submitted'}) fireCommit(pp) }} From 00931f8bcfb6778a45097711f2e5c1a1b95760cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Tue, 21 Apr 2026 09:20:44 +0700 Subject: [PATCH 30/46] docs(oclif): warn about --passphrase visibility in process list (ENG-2002 review B) CLI flag values appear in \`ps aux\` (same UID) and shell history. Explicit warning at point-of-use so users don't need to read the standalone docs first. Repeats the BRV_SSH_PASSPHRASE / ssh-agent guidance already in docs/ssh-commit-signing.md. Bao PR #435 Round 7 Minor #2 / Round 10 Minor B. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/oclif/commands/vc/commit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/oclif/commands/vc/commit.ts b/src/oclif/commands/vc/commit.ts index 38ad616ff..7730b73bd 100644 --- a/src/oclif/commands/vc/commit.ts +++ b/src/oclif/commands/vc/commit.ts @@ -18,7 +18,8 @@ export default class VcCommit extends Command { description: 'Commit message', }), passphrase: Flags.string({ - description: 'SSH key passphrase (or set BRV_SSH_PASSPHRASE env var)', + description: + 'SSH key passphrase โ€” WARNING: visible in process list and shell history. Prefer BRV_SSH_PASSPHRASE env var or ssh-agent.', }), sign: Flags.boolean({ allowNo: true, From 598837a43978aa6adda42d3b17be9962ca36e338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:28:56 +0700 Subject: [PATCH 31/46] fix(ssh): actionable hint for encrypted PEM in extractPublicKey (ENG-2002 review #17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PEM branch of extractPublicKey called parseSSHPrivateKey(keyPath) with no passphrase. For an encrypted PEM key without a .pub sidecar, this surfaced either a misleading "Wrong passphrase" (user never entered one) or a raw OpenSSL code โ€” violating AC9-b/c. Wrap the call with passphrase-error detection and throw a user-grade error pointing at `ssh-keygen -y -f` as the canonical workaround. Affects: `brv signing-key add --key ` and `brv vc config --import-git-signing` when the configured key is an encrypted PEM without a .pub sidecar. `vc commit` is unaffected โ€” it goes through probeSSHKey + explicit passphrase flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/key-parser.ts | 22 +++++++++++-- test/unit/infra/ssh/ssh-key-parser.test.ts | 36 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 699306620..8af47a0ad 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -422,8 +422,26 @@ export async function extractPublicKey(keyPath: string): Promise<{ } // 3. PEM/PKCS8 format: requires the private key to be unencrypted - const parsed = await parseSSHPrivateKey(keyPath) - return {keyType: parsed.keyType, publicKeyBlob: parsed.publicKeyBlob} + // + // An encrypted PEM without a .pub sidecar has no cheap way to derive the + // public key โ€” the material lives inside the encrypted body. Rather than + // let parseSSHPrivateKey's "Wrong passphrase" (misleading โ€” the caller + // never entered one) or a raw OpenSSL code reach the CLI, translate + // passphrase-class failures into an actionable hint pointing at the + // canonical ssh-keygen workaround. ENG-2002 AC9-b/c. + try { + const parsed = await parseSSHPrivateKey(keyPath) + return {keyType: parsed.keyType, publicKeyBlob: parsed.publicKeyBlob} + } catch (error) { + if (isPassphraseError(error)) { + throw new Error( + `Cannot extract public key from encrypted PEM at ${keyPath}. ` + + `Generate a .pub sidecar first:\n\n ssh-keygen -y -f ${keyPath} > ${keyPath}.pub`, + ) + } + + throw error + } } /** diff --git a/test/unit/infra/ssh/ssh-key-parser.test.ts b/test/unit/infra/ssh/ssh-key-parser.test.ts index be98d5511..8e4abcd56 100644 --- a/test/unit/infra/ssh/ssh-key-parser.test.ts +++ b/test/unit/infra/ssh/ssh-key-parser.test.ts @@ -385,6 +385,42 @@ describe('extractPublicKey()', () => { expect(threw).to.be.true }) + + // Regression test for ENG-2002 AC9-b/c (PR #435 review comment #17). + // Before the fix, the PEM branch of extractPublicKey called + // parseSSHPrivateKey(keyPath) with no passphrase. For an encrypted PEM key + // without a .pub sidecar, Node's createPrivateKey emitted either + // ERR_OSSL_BAD_DECRYPT (user-facing: "Wrong passphrase ..." โ€” misleading, + // nobody entered one) or ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED (raw + // OpenSSL code leaks to the CLI โ€” violates AC9 "no raw OpenSSL codes"). + // + // The fix must detect passphrase-class errors from parseSSHPrivateKey and + // replace them with an actionable hint pointing at `ssh-keygen -y -f`. + it('throws actionable hint (not raw OpenSSL / misleading "wrong passphrase") for encrypted PEM with no sidecar', async () => { + const {privateKey} = generateKeyPairSync('ed25519', { + privateKeyEncoding: {cipher: 'aes-256-cbc', format: 'pem', passphrase: 'secret', type: 'pkcs8'}, + publicKeyEncoding: {format: 'pem', type: 'spki'}, + }) + const keyPath = join(tempDir, 'id_ed25519_pem_enc_no_sidecar') + writeFileSync(keyPath, privateKey, {mode: 0o600}) + + let caught: Error | undefined + try { + await extractPublicKey(keyPath) + } catch (error) { + if (error instanceof Error) caught = error + } + + expect(caught, 'extractPublicKey must throw on encrypted PEM without sidecar').to.be.instanceOf(Error) + if (!caught) throw new Error('unreachable') + // Actionable hint โ€” tells the user exactly how to unblock themselves. + expect(caught.message).to.include('ssh-keygen -y -f') + expect(caught.message).to.include(keyPath) + // Must not misleadingly blame a passphrase the user never entered. + expect(caught.message).to.not.match(/Wrong passphrase/i) + // AC9: no raw OpenSSL codes or OpenSSL routine names in user-facing error. + expect(caught.message).to.not.match(/ERR_OSSL|DECODER routines|Provider routines|bad decrypt/i) + }) }) // โ”€โ”€ resolveHome tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ From 03a1f71809b9180b112163912efa0eb7b6e783b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:29:19 +0700 Subject: [PATCH 32/46] chore(ssh): remove unwired scrubPassphrase helper (ENG-2002 review #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added in 2e4d228b with the intent of safe payload logging, but never wired into any log or error-construction site. The commit path builds VcError instances with pre-formatted messages rather than serialising the incoming payload, so there is no consumer for the helper today. Delete rather than wire speculatively โ€” re-introduce with a concrete log site if one ever materialises. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/shared/ssh/scrub-passphrase.ts | 16 ------- test/unit/shared/ssh/scrub-passphrase.test.ts | 43 ------------------- 2 files changed, 59 deletions(-) delete mode 100644 src/shared/ssh/scrub-passphrase.ts delete mode 100644 test/unit/shared/ssh/scrub-passphrase.test.ts diff --git a/src/shared/ssh/scrub-passphrase.ts b/src/shared/ssh/scrub-passphrase.ts deleted file mode 100644 index 292d27f6c..000000000 --- a/src/shared/ssh/scrub-passphrase.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Redact a `passphrase` field from an object while preserving the rest. - * - * Use this wherever a request/response payload that may carry a passphrase - * is about to be logged, serialised into an error, stringified for telemetry, - * or echoed back to a client: never let the raw secret leave the in-memory - * handler that consumes it. - * - * Generic over any object that has an optional `passphrase: string` โ€” which - * today is `IVcCommitRequest` (shared/transport/events/vc-events.ts) but may - * grow in the future. - */ -export function scrubPassphrase(payload: T): T { - if (payload.passphrase === undefined || payload.passphrase === '') return payload - return {...payload, passphrase: '***'} -} diff --git a/test/unit/shared/ssh/scrub-passphrase.test.ts b/test/unit/shared/ssh/scrub-passphrase.test.ts deleted file mode 100644 index 41a5312ed..000000000 --- a/test/unit/shared/ssh/scrub-passphrase.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {expect} from 'chai' - -import {scrubPassphrase} from '../../../../src/shared/ssh/scrub-passphrase.js' - -describe('scrubPassphrase()', () => { - it('redacts a non-empty passphrase to "***"', () => { - const input = {message: 'hi', passphrase: 'supersecret'} - const output = scrubPassphrase(input) - expect(output.passphrase).to.equal('***') - expect(output.message).to.equal('hi') - }) - - it('preserves all other fields unchanged', () => { - const input = {message: 'msg', passphrase: 's', sign: true} - const output = scrubPassphrase(input) - expect(output).to.deep.equal({message: 'msg', passphrase: '***', sign: true}) - }) - - it('returns the same reference when passphrase is undefined', () => { - const input: {message: string; passphrase?: string} = {message: 'hi'} - const output = scrubPassphrase(input) - expect(output).to.equal(input) - }) - - it('returns the same reference when passphrase is the empty string', () => { - const input = {message: 'hi', passphrase: ''} - const output = scrubPassphrase(input) - expect(output).to.equal(input) - }) - - it('does not mutate the input object', () => { - const input = {message: 'hi', passphrase: 'secret'} - scrubPassphrase(input) - expect(input.passphrase).to.equal('secret') - }) - - it('works on shapes beyond IVcCommitRequest (generic)', () => { - const input = {passphrase: 'x', unrelated: [1, 2, 3]} - const output = scrubPassphrase(input) - expect(output.passphrase).to.equal('***') - expect(output.unrelated).to.deep.equal([1, 2, 3]) - }) -}) From 5c78743b05cc8b33dc8be16ec5611db856e1671f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:30:39 +0700 Subject: [PATCH 33/46] test(signing-key): drop fake 'returns mapped key list' test (ENG-2002 review #23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test under describe('list action') asserted only that the request handler is a function โ€” not that `list` maps IAM responses. Setup registration is covered by describe('setup'), and the injectable-service seam tests under describe('action routing (via injectable service seam)') already exercise the real list โ†’ mapResource โ†’ toSigningKeyItem path. Removing the fake eliminates a false coverage signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/signing-key-handler.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/test/unit/infra/transport/handlers/signing-key-handler.test.ts b/test/unit/infra/transport/handlers/signing-key-handler.test.ts index 3f5ec283f..6721f362b 100644 --- a/test/unit/infra/transport/handlers/signing-key-handler.test.ts +++ b/test/unit/infra/transport/handlers/signing-key-handler.test.ts @@ -192,22 +192,6 @@ describe('SigningKeyHandler', () => { }) }) - describe('list action', () => { - it('returns mapped key list', async () => { - const deps = makeDeps(sandbox) - const {getRequestHandler} = makeHandler(sandbox, deps) - - // The handler creates its own HttpSigningKeyService internally. - // We test the auth check path; for the actual IAM call we rely on http-signing-key-service tests. - // Here we just verify the flow doesn't throw when auth is valid. - // Since we can't easily stub the internal HttpSigningKeyService (ES module instantiation), - // we verify the NotAuthenticatedError path (above) and rely on integration for IAM calls. - // This test verifies setup registers the event handler. - const handler = getRequestHandler() - expect(handler).to.be.a('function') - }) - }) - describe('setup', () => { it('registers handler for VcEvents.SIGNING_KEY', () => { const deps = makeDeps(sandbox) From 7c04d979dbb98b3a5ce3036e69b9e535681826ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:48:37 +0700 Subject: [PATCH 34/46] fix(vc): invalidate cache entry for the previous signing-key on config change (ENG-2002 review #14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user ran `brv vc config user.signingkey `, handleConfig cached the new ParsedSSHKey but did not evict the entry for the previously configured path. The stale ParsedSSHKey (with its decrypted crypto.KeyObject) sat in daemon memory until its 30-min TTL expired โ€” low-severity but avoidable leak since SigningKeyCache already exposes invalidate(projectPath, keyPath) for exactly this case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../infra/transport/handlers/vc-handler.ts | 7 ++ .../transport/handlers/vc-handler.test.ts | 65 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 305a6e41f..4743c9c98 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -759,6 +759,13 @@ export class VcHandler { } } + // Evict any cached ParsedSSHKey bound to the PREVIOUS key path โ€” + // otherwise a stale entry keeps the old decrypted key object in daemon + // memory until its 30-min TTL expires. + if (existing.signingKey && existing.signingKey !== resolvedPath) { + this.signingKeyCache.invalidate(projectPath, existing.signingKey) + } + await this.vcGitConfigStore.set(projectPath, {...existing, signingKey: resolvedPath}) return {hint, key: data.key, value: resolvedPath} } diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index f08ad7f11..7272dea55 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -28,6 +28,7 @@ import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-conf import {GitAuthError, GitError} from '../../../../../src/server/core/domain/errors/git-error.js' import {NotAuthenticatedError} from '../../../../../src/server/core/domain/errors/task-error.js' import {VcError} from '../../../../../src/server/core/domain/errors/vc-error.js' +import {SigningKeyCache} from '../../../../../src/server/infra/ssh/signing-key-cache.js' import {VcHandler} from '../../../../../src/server/infra/transport/handlers/vc-handler.js' import { type IVcBranchRequest, @@ -188,7 +189,7 @@ function makeDeps(sandbox: SinonSandbox, projectPath: string): TestDeps { } } -function makeVcHandler(deps: TestDeps): VcHandler { +function makeVcHandler(deps: TestDeps, signingKeyCache?: SigningKeyCache): VcHandler { return new VcHandler({ broadcastToProject: deps.broadcastToProject, contextTreeService: deps.contextTreeService, @@ -196,6 +197,7 @@ function makeVcHandler(deps: TestDeps): VcHandler { gitService: deps.gitService, projectConfigStore: deps.projectConfigStore, resolveProjectPath: deps.resolveProjectPath, + ...(signingKeyCache ? {signingKeyCache} : {}), spaceService: deps.spaceService, teamService: deps.teamService, tokenStore: deps.tokenStore, @@ -1112,6 +1114,67 @@ BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== } }) + // Regression test for PR #435 review comment #14: when the user changes + // user.signingkey, the cache entry for the PREVIOUS key path must be + // evicted. Otherwise the old ParsedSSHKey sits in daemon memory until its + // 30-min TTL expires โ€” low-severity leak, but the cache exposes an + // invalidate(projectPath, keyPath) API for exactly this purpose. + it('handleConfig user.signingkey: invalidates cache entry for the previous key path', async () => { + const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-config-cache-invalidate-')) + const oldKeyPath = join(realProjectPath, 'old_key') + const newKeyPath = join(realProjectPath, 'new_key') + const fakeKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + writeFileSync(oldKeyPath, fakeKey, {mode: 0o600}) + writeFileSync(newKeyPath, fakeKey, {mode: 0o600}) + + const cache = new SigningKeyCache() + // Parse once via the real parser so we have an authentic ParsedSSHKey to seed. + const {parseSSHPrivateKey} = await import('../../../../../src/server/infra/ssh/ssh-key-parser.js') + const parsed = await parseSSHPrivateKey(oldKeyPath) + cache.set(projectPath, oldKeyPath, parsed) + + const deps = makeDeps(sandbox, projectPath) + deps.vcGitConfigStore.get.resolves({signingKey: oldKeyPath}) + makeVcHandler(deps, cache).setup() + + await deps.requestHandlers[VcEvents.CONFIG]({key: 'user.signingkey', value: newKeyPath}, CLIENT_ID) + + // Old entry must be gone (get() returns a falsy value once the entry + // is evicted โ€” null today, undefined after review comment #11). + expect(cache.get(projectPath, oldKeyPath), 'old cache entry should be invalidated').to.not.be.ok + + rmSync(realProjectPath, {force: true, recursive: true}) + }) + + // Regression test for PR #435 review comment #16: user.signingkey paths + // must be absolute. Relative paths silently resolve against the daemon's + // CWD and break if the daemon restarts from a different working + // directory. Matches git's own `user.signingKey` semantic. + it('handleConfig user.signingkey: rejects non-absolute path with INVALID_CONFIG_VALUE', async () => { + const deps = makeDeps(sandbox, projectPath) + deps.vcGitConfigStore.get.resolves({}) + makeVcHandler(deps).setup() + + try { + await deps.requestHandlers[VcEvents.CONFIG]({key: 'user.signingkey', value: './id_ed25519'}, CLIENT_ID) + expect.fail('Expected VcError') + } catch (error) { + expect(error).to.be.instanceOf(VcError) + if (error instanceof VcError) { + expect(error.code).to.equal(VcErrorCode.INVALID_CONFIG_VALUE) + expect(error.message).to.match(/absolute/i) + } + } + + expect(deps.vcGitConfigStore.set.called, 'must not write config when path is rejected').to.be.false + }) + it('handleImportGitSigning: does NOT set commitSign when gpgsign is explicitly false', async () => { const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-nosign-')) mkdirSync(realProjectPath, {recursive: true}) From e8367064e3da10eaa8067b1cca202e072108e915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:49:23 +0700 Subject: [PATCH 35/46] fix(vc): reject non-absolute signing-key path in config (ENG-2002 review #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveHome only expands a leading ~. Relative paths like `./id_ed25519` or bare `id_ed25519` passed straight through and then resolved against the daemon's CWD โ€” so the same config value worked today and broke tomorrow if the daemon restarted from a different directory. Reject non-absolute paths at both entry points (handleConfig set and handleImportGitSigning) with INVALID_CONFIG_VALUE. Matches the semantic of `git config user.signingKey`, which also requires an absolute path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../infra/transport/handlers/vc-handler.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 4743c9c98..7272284d3 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -1,6 +1,6 @@ import {execFile} from 'node:child_process' import fs from 'node:fs' -import {join} from 'node:path' +import path, {join} from 'node:path' import {promisify} from 'node:util' import type {ITokenStore} from '../../../core/interfaces/auth/i-token-store.js' @@ -733,6 +733,16 @@ export class VcHandler { resolvedPath = resolvedPath.slice(0, -4) } + // Reject relative paths โ€” they silently resolve against the daemon's + // CWD and would break the next time the daemon starts from a + // different directory. Matches git's own `user.signingKey` semantic. + if (!path.isAbsolute(resolvedPath)) { + throw new VcError( + `Signing key path must be absolute. Got: ${resolvedPath}`, + VcErrorCode.INVALID_CONFIG_VALUE, + ) + } + const probe = await probeSSHKey(resolvedPath) if (!probe.exists) { throw new VcError( @@ -938,6 +948,14 @@ export class VcHandler { resolvedPath = resolvedPath.slice(0, -4) } + // Reject relative paths imported from git config. See handleConfig for rationale. + if (!path.isAbsolute(resolvedPath)) { + throw new VcError( + `Signing key path from git config must be absolute. Got: ${resolvedPath}`, + VcErrorCode.INVALID_CONFIG_VALUE, + ) + } + const probe = await probeSSHKey(resolvedPath) if (!probe.exists) { throw new VcError( From c368efd071362b8690ff3790db04692b319b29d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:51:13 +0700 Subject: [PATCH 36/46] fix(vc): distinguish passphrase vs other parse errors in config hint (ENG-2002 review #18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleConfig (and handleImportGitSigning) wrapped parseSSHPrivateKey in an empty catch block, so any non-passphrase failure (corrupt file, race between probe and parse, unexpected format) disappeared silently. The config set returned success with no hint and no warning โ€” the user only discovered the problem at the next `brv vc commit --sign`, with no breadcrumb back to the config step. Detect passphrase-class errors via isPassphraseError (exported for this use) and skip the hint for those (intended behavior โ€” encrypted keys need the passphrase to derive a fingerprint). For any other error, surface the message in the hint so the failure is visible at the point the user took the action. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/index.ts | 2 +- src/server/infra/ssh/ssh-key-parser.ts | 1 + .../infra/transport/handlers/vc-handler.ts | 21 ++++++++++++++----- src/shared/ssh/key-parser.ts | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/server/infra/ssh/index.ts b/src/server/infra/ssh/index.ts index 0d8603c45..023559598 100644 --- a/src/server/infra/ssh/index.ts +++ b/src/server/infra/ssh/index.ts @@ -1,5 +1,5 @@ export {SigningKeyCache} from './signing-key-cache.js' export {SshAgentSigner, tryGetSshAgentSigner} from './ssh-agent-signer.js' -export {parseSSHPrivateKey, probeSSHKey, resolveHome} from './ssh-key-parser.js' +export {isPassphraseError, parseSSHPrivateKey, probeSSHKey, resolveHome} from './ssh-key-parser.js' export {signCommitPayload} from './sshsig-signer.js' export type {ParsedSSHKey, SSHKeyProbe, SSHKeyType, SSHSignatureResult} from './types.js' diff --git a/src/server/infra/ssh/ssh-key-parser.ts b/src/server/infra/ssh/ssh-key-parser.ts index 86bb94d5f..254f28b55 100644 --- a/src/server/infra/ssh/ssh-key-parser.ts +++ b/src/server/infra/ssh/ssh-key-parser.ts @@ -4,6 +4,7 @@ export { computeFingerprint, extractPublicKey, getPublicKeyMetadata, + isPassphraseError, parseSSHPrivateKey, probeSSHKey, resolveHome, diff --git a/src/server/infra/transport/handlers/vc-handler.ts b/src/server/infra/transport/handlers/vc-handler.ts index 7272284d3..abfe6ea7f 100644 --- a/src/server/infra/transport/handlers/vc-handler.ts +++ b/src/server/infra/transport/handlers/vc-handler.ts @@ -62,6 +62,7 @@ import {VcError} from '../../../core/domain/errors/vc-error.js' import {ensureContextTreeGitignore, ensureGitignoreEntries} from '../../../utils/gitignore.js' import {buildCogitRemoteUrl, isValidBranchName, parseUserFacingUrl} from '../../git/cogit-url.js' import { + isPassphraseError, type ParsedSSHKey, parseSSHPrivateKey, probeSSHKey, @@ -764,8 +765,14 @@ export class VcHandler { // Cache the parsed key for immediate use this.signingKeyCache.set(projectPath, resolvedPath, parsed) hint = `Fingerprint: ${parsed.fingerprint}` - } catch { - // Encrypted key โ€” require passphrase to get fingerprint; skip hint + } catch (error) { + // Encrypted key โ€” need passphrase for fingerprint; skip hint silently. + // Anything else (corrupt file, unexpected format, race between probe + // and parse) surfaces as a hint so the user gets a breadcrumb at + // config time rather than a surprise at `brv vc commit --sign`. + if (!isPassphraseError(error)) { + hint = `Could not compute fingerprint: ${error instanceof Error ? error.message : String(error)}` + } } } @@ -970,13 +977,17 @@ export class VcHandler { const updated: typeof existing = {...existing, commitSign, signingKey: resolvedPath} await this.vcGitConfigStore.set(projectPath, updated) - let hint: string | undefined + let hint = `commit.sign: ${String(commitSign)}` try { const parsed = await parseSSHPrivateKey(resolvedPath) this.signingKeyCache.set(projectPath, resolvedPath, parsed) hint = `Fingerprint: ${parsed.fingerprint} | commit.sign: ${String(commitSign)}` - } catch { - hint = `commit.sign: ${String(commitSign)}` + } catch (error) { + // Encrypted key: fingerprint unavailable without passphrase, that's fine. + // Anything else: surface alongside commit.sign so the user notices now. + if (!isPassphraseError(error)) { + hint = `${hint} | Could not compute fingerprint: ${error instanceof Error ? error.message : String(error)}` + } } return {hint, key: 'user.signingkey', value: resolvedPath} diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 8af47a0ad..2639f54f4 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -184,7 +184,7 @@ function opensshEd25519ToNodeKey(privateKeyBlob: Buffer): { } /** Detect whether a PEM key parsing error indicates an encrypted key needing a passphrase. */ -function isPassphraseError(err: unknown): boolean { +export function isPassphraseError(err: unknown): boolean { if (!(err instanceof Error)) return false // Node.js crypto errors expose an `code` property. Whitelist exactly the two From 29658ccee69157ab6896f618c8538b6db0b459d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:52:22 +0700 Subject: [PATCH 37/46] fix(tui): reset commit mutation to drop passphrase after terminal state (ENG-2002 review #26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React-Query retains mutation.variables until `reset()` is called explicitly or gcTime elapses (default 5 min). Because this PR added `passphrase` to the mutation payload, the plaintext passphrase lived in TUI memory for ~5 min after every successful commit โ€” exposed to devtools, error boundaries, and any diagnostic dumper that walks the query cache. Call commitMutation.reset() in the same useEffect that bubbles the terminal state up to the dialog manager. No dedicated test: the project has no React/Ink testing library wired up, and the change is a one-liner inside a clearly-scoped useEffect. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../features/vc/commit/components/vc-commit-flow.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tui/features/vc/commit/components/vc-commit-flow.tsx b/src/tui/features/vc/commit/components/vc-commit-flow.tsx index 805650649..cdfac9873 100644 --- a/src/tui/features/vc/commit/components/vc-commit-flow.tsx +++ b/src/tui/features/vc/commit/components/vc-commit-flow.tsx @@ -54,12 +54,19 @@ export function VcCommitFlow({message, onCancel, onComplete}: VcCommitFlowProps) fireCommit() }, [fireCommit]) - // Terminal state โ†’ bubble up to dialog manager + // Terminal state โ†’ bubble up to dialog manager. + // + // Also drop the passphrase from React-Query's mutation.variables. The + // library retains variables until `reset()` or GC (~5 min), so without + // this the plaintext passphrase keeps living in TUI memory after the + // commit succeeds โ€” visible to devtools, error boundaries, diagnostic + // dumpers. Cleared even on error/cancel terminal states for symmetry. useEffect(() => { if (state.kind === 'done') { + commitMutation.reset() onComplete(state.message) } - }, [state, onComplete]) + }, [state, onComplete, commitMutation]) if (state.kind === 'awaiting-passphrase') { return ( From c0b44fe8237ee49f58132617afab5efbc1a733c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:54:26 +0700 Subject: [PATCH 38/46] fix(ssh): guard agent response length and sigLen bounds (ENG-2002 review #21+#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two protocol-hardening guards against a misbehaving or truncated SSH agent response: #21 โ€” request() used to resolve with whatever bytes the agent delivered, even when the body was zero-length. Downstream `response[0] !== SSH_AGENT_IDENTITIES_ANSWER` then formatted "undefined" into the error message, losing the actual fault. Reject empty bodies at the request() boundary with a clear message. #22 โ€” sign() read `sigLen = readUInt32(response, 1)` and returned `subarray(5, 5 + sigLen)` without checking the declared length against the actual body length. A truncated response yielded a short signature silently; downstream `crypto.verify()` would then reject it with a generic "bad signature" error instead of surfacing the agent-boundary problem. Compare sigLen against `response.length - 5` and throw a descriptive error when mismatched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/ssh-agent-signer.ts | 14 ++++- test/unit/infra/ssh/ssh-agent-signer.test.ts | 66 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index e90eb9e6b..1d5f00074 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -115,7 +115,13 @@ class SshAgentClient { } if (accumulated.length >= 4 + responseLen) { - settle(() => resolve(accumulated.subarray(4, 4 + responseLen))) + const body = accumulated.subarray(4, 4 + responseLen) + if (body.length === 0) { + settle(() => reject(new Error('Agent returned empty response body'))) + return + } + + settle(() => resolve(body)) } } }) @@ -157,6 +163,12 @@ class SshAgentClient { // Response: byte SSH_AGENT_SIGN_RESPONSE + string(signature) const sigLen = readUInt32(response, 1) + if (sigLen > response.length - 5) { + throw new Error( + `Agent signature truncated: header claims ${sigLen} bytes, body has ${response.length - 5}`, + ) + } + return response.subarray(5, 5 + sigLen) } } diff --git a/test/unit/infra/ssh/ssh-agent-signer.test.ts b/test/unit/infra/ssh/ssh-agent-signer.test.ts index 05e7afe18..d8495b8a3 100644 --- a/test/unit/infra/ssh/ssh-agent-signer.test.ts +++ b/test/unit/infra/ssh/ssh-agent-signer.test.ts @@ -376,5 +376,71 @@ describe('SshAgentSigner.sign()', () => { agent.cleanup() } }) + + // Regression test for PR #435 review comment #22: sign() used to blindly + // return response.subarray(5, 5 + sigLen) without checking that sigLen + // actually fits in the response buffer. A truncated agent response + // silently yielded a short signature; downstream verification would + // then fail with a generic "bad signature" error instead of surfacing + // the agent-boundary problem at its source. + it('throws when agent response claims a signature length larger than the body', async () => { + const parsed = await parseSSHPrivateKey(keyPath) + + // Build a malformed SIGN_RESPONSE: type byte + sigLen(=100) + only 3 bytes of signature. + // The outer request/response length prefix is added by the mock server. + const malformedBody = Buffer.concat([ + Buffer.from([SSH_AGENT_SIGN_RESPONSE]), + writeUInt32BE(100), + Buffer.from('ABC'), + ]) + + const tempDir2 = mkdtempSync(join(tmpdir(), 'brv-mock-agent-trunc-')) + const socketPath = join(tempDir2, 'agent.sock') + const server = net.createServer((conn) => { + const chunks: Buffer[] = [] + conn.on('data', (chunk) => { + chunks.push(chunk) + const acc = Buffer.concat(chunks) + if (acc.length < 4) return + const msgLen = acc.readUInt32BE(0) + if (acc.length < 4 + msgLen) return + const payload = acc.subarray(4, 4 + msgLen) + chunks.length = 0 + + if (payload[0] === SSH_AGENTC_REQUEST_IDENTITIES) { + const body = Buffer.concat([ + Buffer.from([SSH_AGENT_IDENTITIES_ANSWER]), + writeUInt32BE(1), + sshString(parsed.publicKeyBlob), + sshString('comment'), + ]) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } else if (payload[0] === SSH_AGENTC_SIGN_REQUEST) { + conn.write(Buffer.concat([writeUInt32BE(malformedBody.length), malformedBody])) + } + }) + }) + server.listen(socketPath) + process.env.SSH_AUTH_SOCK = socketPath + + try { + const signer = await tryGetSshAgentSigner(keyPath) + expect(signer, 'signer should be constructed').to.not.be.null + if (!signer) throw new Error('unreachable') + + let caught: Error | undefined + try { + await signer.sign('payload') + } catch (error) { + if (error instanceof Error) caught = error + } + + expect(caught, 'sign() must reject on truncated response').to.be.instanceOf(Error) + if (!caught) throw new Error('unreachable') + expect(caught.message).to.match(/truncat|signature.*byte/i) + } finally { + server.close() + } + }) }) }) From 637a0616b13744ecbff10fa90f1ebe1f2ee708f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:58:29 +0700 Subject: [PATCH 39/46] fix(vc): detect signing-key duplicate via VcErrorCode + check response.success (ENG-2002 review #19+#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #19 โ€” `brv vc config --import-git-signing` previously sniffed four English substrings ("already exists", "Duplicate", "ALREADY_EXISTS", "409") on the error message to decide whether a key was already registered. If IAM ever returned "Conflict: ...", "RESOURCE_EXISTS", "DUPLICATE_KEY", or any localized variant, the substring check fell through into the "run brv signing-key add" hint โ€” which would then 409 again. Translate the HTTP 409 at the boundary that knows about it (HttpSigningKeyService) into a new VcErrorCode.SIGNING_KEY_ALREADY_EXISTS, and match that code structurally at the CLI via TransportRequestError.code. #20 โ€” listKeys silently returned [] when the response envelope carried `{success: false}`. Callers had no way to distinguish "no keys" from "request semantically failed." AuthenticatedHttpClient throws on non-2xx responses, so this only bites on 200 OK + success:false โ€” but that's an envelope we shouldn't trust implicitly. addKey and listKeys now both throw on success:false for symmetric defensive posture. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/oclif/commands/vc/config.ts | 20 ++++++- .../infra/iam/http-signing-key-service.ts | 40 +++++++++++-- src/shared/transport/events/vc-events.ts | 1 + .../iam/http-signing-key-service.test.ts | 58 +++++++++++++++++++ 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/oclif/commands/vc/config.ts b/src/oclif/commands/vc/config.ts index 95e364e85..2a3b28ad2 100644 --- a/src/oclif/commands/vc/config.ts +++ b/src/oclif/commands/vc/config.ts @@ -2,9 +2,23 @@ import {Args, Command, Flags} from '@oclif/core' import {existsSync, readFileSync} from 'node:fs' import {extractPublicKey, resolveHome} from '../../../shared/ssh/index.js' -import {isVcConfigKey, type IVcConfigResponse, type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' +import {isVcConfigKey, type IVcConfigResponse, type IVcSigningKeyResponse, VcErrorCode, VcEvents} from '../../../shared/transport/events/vc-events.js' import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' +/** + * Returns the transport-error code (e.g. VcErrorCode.SIGNING_KEY_ALREADY_EXISTS) + * from errors coming back through the daemon transport. TransportRequestError + * preserves `.code`; other error shapes return undefined. + */ +function getTransportErrorCode(error: unknown): string | undefined { + if (error instanceof Error && 'code' in error) { + const {code} = error as Error & {code?: unknown} + if (typeof code === 'string') return code + } + + return undefined +} + export default class VcConfig extends Command { public static args = { key: Args.string({ @@ -115,10 +129,10 @@ public static flags = { this.log('โœ… Signing key registered successfully on server') } } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error) - if (errMsg.includes('already exists') || errMsg.includes('Duplicate') || errMsg.includes('ALREADY_EXISTS') || errMsg.includes('409')) { + if (getTransportErrorCode(error) === VcErrorCode.SIGNING_KEY_ALREADY_EXISTS) { this.log('โœ… Signing key is already registered on server') } else { + const errMsg = error instanceof Error ? error.message : String(error) this.log(`โš ๏ธ Could not automatically register key: ${errMsg}`) this.log(` You may need to run: brv signing-key add --key ${result.value}`) } diff --git a/src/server/infra/iam/http-signing-key-service.ts b/src/server/infra/iam/http-signing-key-service.ts index 656edd0c5..c9f73cc5e 100644 --- a/src/server/infra/iam/http-signing-key-service.ts +++ b/src/server/infra/iam/http-signing-key-service.ts @@ -1,6 +1,9 @@ import type {IHttpClient} from '../../core/interfaces/services/i-http-client.js' import type {ISigningKeyService, SigningKeyResource} from '../../core/interfaces/services/i-signing-key-service.js' +import {VcErrorCode} from '../../../shared/transport/events/vc-events.js' +import {VcError} from '../../core/domain/errors/vc-error.js' + // IAM API wraps all responses in {success, data} interface ApiEnvelope { data: T @@ -54,12 +57,33 @@ export class HttpSigningKeyService implements ISigningKeyService { } async addKey(title: string, publicKey: string): Promise { - const response = await this.httpClient.post>( - `${this.iamBaseUrl}/api/v3/users/me/signing-keys`, - /* eslint-disable camelcase */ - {public_key: publicKey, title}, - /* eslint-enable camelcase */ - ) + let response: ApiEnvelope + try { + response = await this.httpClient.post>( + `${this.iamBaseUrl}/api/v3/users/me/signing-keys`, + /* eslint-disable camelcase */ + {public_key: publicKey, title}, + /* eslint-enable camelcase */ + ) + } catch (error) { + // AuthenticatedHttpClient collapses non-2xx axios errors into Error + // instances whose message carries the HTTP status. Translate the + // duplicate-key signal (409) into a structured VcError so callers can + // branch on the code instead of regex-matching English substrings. + if (error instanceof Error && /\b409\b|conflict/i.test(error.message)) { + throw new VcError( + 'This SSH public key is already registered with your Byterover account.', + VcErrorCode.SIGNING_KEY_ALREADY_EXISTS, + ) + } + + throw error + } + + if (!response.success) { + throw new Error('IAM signing-key add request failed (response.success=false)') + } + return mapResource(response.data.signing_key) } @@ -67,6 +91,10 @@ export class HttpSigningKeyService implements ISigningKeyService { const response = await this.httpClient.get>( `${this.iamBaseUrl}/api/v3/users/me/signing-keys`, ) + if (!response.success) { + throw new Error('IAM signing-key list request failed (response.success=false)') + } + return (response.data.signing_keys ?? []).map((raw) => mapResource(raw)) } diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index c8d8e8f3d..82f21cbcf 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -33,6 +33,7 @@ export const VcErrorCode = { PULL_FAILED: 'ERR_VC_PULL_FAILED', PUSH_FAILED: 'ERR_VC_PUSH_FAILED', REMOTE_ALREADY_EXISTS: 'ERR_VC_REMOTE_ALREADY_EXISTS', + SIGNING_KEY_ALREADY_EXISTS: 'ERR_VC_SIGNING_KEY_ALREADY_EXISTS', SIGNING_KEY_NOT_CONFIGURED: 'ERR_VC_SIGNING_KEY_NOT_CONFIGURED', SIGNING_KEY_NOT_FOUND: 'ERR_VC_SIGNING_KEY_NOT_FOUND', SIGNING_KEY_NOT_SUPPORTED: 'ERR_VC_SIGNING_KEY_NOT_SUPPORTED', diff --git a/test/unit/infra/iam/http-signing-key-service.test.ts b/test/unit/infra/iam/http-signing-key-service.test.ts index b36e8bd9a..8d46cade9 100644 --- a/test/unit/infra/iam/http-signing-key-service.test.ts +++ b/test/unit/infra/iam/http-signing-key-service.test.ts @@ -7,7 +7,9 @@ import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' import type {IHttpClient} from '../../../../src/server/core/interfaces/services/i-http-client.js' +import {VcError} from '../../../../src/server/core/domain/errors/vc-error.js' import {HttpSigningKeyService} from '../../../../src/server/infra/iam/http-signing-key-service.js' +import {VcErrorCode} from '../../../../src/shared/transport/events/vc-events.js' type Stubbed = {[K in keyof T]: SinonStub & T[K]} @@ -81,6 +83,41 @@ describe('HttpSigningKeyService', () => { title: 'My laptop', }) }) + + // Regression test for PR #435 review comment #19: dup-detection at the + // CLI used to sniff four different English substrings on the error + // message. The IAM server already communicates duplicate intent via HTTP + // 409; the HTTP adapter is the right layer to translate that into a + // structured VcError whose code the CLI can match exactly. + it('throws VcError SIGNING_KEY_ALREADY_EXISTS when underlying client reports HTTP 409', async () => { + httpClient.post.rejects(new Error('HTTP 409: Conflict')) + + let caught: unknown + try { + await service.addKey('My laptop', 'ssh-ed25519 AAAA...') + } catch (error) { + caught = error + } + + expect(caught).to.be.instanceOf(VcError) + if (caught instanceof VcError) { + expect(caught.code).to.equal(VcErrorCode.SIGNING_KEY_ALREADY_EXISTS) + } + }) + + it('passes through other HTTP errors unchanged', async () => { + const original = new Error('HTTP 500: Internal Server Error') + httpClient.post.rejects(original) + + let caught: unknown + try { + await service.addKey('My laptop', 'ssh-ed25519 AAAA...') + } catch (error) { + caught = error + } + + expect(caught).to.equal(original) + }) }) describe('listKeys()', () => { @@ -118,6 +155,27 @@ describe('HttpSigningKeyService', () => { expect(keys).to.deep.equal([]) }) + + // Regression test for PR #435 review comment #20: even though + // AuthenticatedHttpClient throws on non-2xx responses, a 200 OK with + // `{success: false}` would previously slip past listKeys unnoticed and + // return []. Callers had no way to distinguish "no keys" from "request + // silently failed". Surface that state as an explicit throw. + it('throws when response envelope reports success:false', async () => { + httpClient.get.resolves({ + data: {signing_keys: []}, // eslint-disable-line camelcase + success: false, + }) + + let caught: unknown + try { + await service.listKeys() + } catch (error) { + caught = error + } + + expect(caught).to.be.instanceOf(Error) + }) }) describe('removeKey()', () => { From 6dc8df20bbd56a03ec14db09887429dc6cb68b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 11:59:36 +0700 Subject: [PATCH 40/46] test(vc): handleImportGitSigning picks up user.signingKey from global gitconfig (ENG-2002 review #24) Existing handleImportGitSigning tests all write config into the per-repo local config, but the most common real-world setup is a single user.signingKey in ~/.gitconfig. If a later refactor skips the global lookup, every existing test still passes while the most common user configuration ships broken. Add a regression test that points GIT_CONFIG_GLOBAL at a temp config file (so git child processes inherit it) and asserts the signingKey is imported with no local config involvement. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../transport/handlers/vc-handler.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/unit/infra/transport/handlers/vc-handler.test.ts b/test/unit/infra/transport/handlers/vc-handler.test.ts index 7272dea55..7df27e7d7 100644 --- a/test/unit/infra/transport/handlers/vc-handler.test.ts +++ b/test/unit/infra/transport/handlers/vc-handler.test.ts @@ -1175,6 +1175,62 @@ BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== expect(deps.vcGitConfigStore.set.called, 'must not write config when path is rejected').to.be.false }) + // Regression test for PR #435 review comment #24. handleImportGitSigning + // reads via `git config --get `, which resolves local โ†’ global โ†’ + // system. The existing tests all set the value in local repo config โ€” + // the real-world common setup is a single `user.signingKey` in + // ~/.gitconfig. If someone refactored the implementation to skip the + // global lookup, every existing test still passed while the most + // common user configuration would ship broken. + it('handleImportGitSigning: reads signingKey from GIT_CONFIG_GLOBAL when no local repo config exists', async () => { + const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-global-')) + mkdirSync(realProjectPath, {recursive: true}) + const {execSync} = await import('node:child_process') + execSync('git init', {cwd: realProjectPath}) + // Fresh repo has no local user.signingKey, so resolution falls through + // to GIT_CONFIG_GLOBAL โ€” no explicit --unset needed. + + const keyPath = join(realProjectPath, 'global_key') + const fakeKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQgAAAJgCtf3VArX9 +1QAAAAtzc2gtZWQyNTUxOQAAACAmIfT6LJouOpJugPKYl7yiJwYIlrh124TOYjaNzxjNQg +AAEB01GDi+m4swI3lsGv870+yJFfAJP0CcFSDPcTyCUpaBSYh9Posmi46km6A8piXvKIn +BgiWuHXbhM5iNo3PGM1CAAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----` + writeFileSync(keyPath, fakeKey, {mode: 0o600}) + + // Write ONLY a global gitconfig (pointed to via GIT_CONFIG_GLOBAL). + const globalConfigPath = join(realProjectPath, 'fake_gitconfig') + writeFileSync(globalConfigPath, `[user]\n\tsigningKey = ${keyPath}\n[gpg]\n\tformat = ssh\n[commit]\n\tgpgSign = true\n`) + + // Point git's global-config lookup at our temp file for the duration + // of this test (handler spawns git as a child process and inherits env). + const originalGlobal = process.env.GIT_CONFIG_GLOBAL + process.env.GIT_CONFIG_GLOBAL = globalConfigPath + + try { + const deps = makeDeps(sandbox, realProjectPath) + deps.vcGitConfigStore.get.resolves({}) + makeVcHandler(deps).setup() + + await deps.requestHandlers[VcEvents.CONFIG]({importGitSigning: true}, CLIENT_ID) + + expect(deps.vcGitConfigStore.set.calledOnce, 'config must be written').to.be.true + const savedConfig = deps.vcGitConfigStore.set.firstCall.args[1] + expect(savedConfig.signingKey).to.equal(keyPath) + expect(savedConfig.commitSign).to.equal(true) + } finally { + if (originalGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL + } else { + process.env.GIT_CONFIG_GLOBAL = originalGlobal + } + + rmSync(realProjectPath, {force: true, recursive: true}) + } + }) + it('handleImportGitSigning: does NOT set commitSign when gpgsign is explicitly false', async () => { const realProjectPath = fs.mkdtempSync(join(tmpdir(), 'brv-test-import-nosign-')) mkdirSync(realProjectPath, {recursive: true}) From 9f3225e1e7d86e16e9f27eb34d2cd0ea0315cbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 12:01:05 +0700 Subject: [PATCH 41/46] test(ssh): RSA-via-agent sets SSH_AGENT_RSA_SHA2_512 flag (ENG-2002 review #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing agent-signer test uses ed25519, so the RSA-specific branch (`flags = this.keyType === 'ssh-rsa' ? SSH_AGENT_RSA_SHA2_512 : 0`) is untested. A refactor from `=== 'ssh-rsa'` to `.startsWith('rsa-')` or `=== 'rsa'` would go green across the whole suite while silently signing RSA with SHA-1 โ€” breaking the v1 recovery path documented for users whose encrypted ~/.ssh/id_rsa lives in ssh-agent. This test drives tryGetSshAgentSigner via a .pub sidecar labeled "ssh-rsa" (keyType detection happens there) and a mock agent that captures the flags uint32 from the SIGN request. Asserting capturedFlags === 4 pins the SSH_AGENT_RSA_SHA2_512 branch exactly. Full crypto round-trip (verify the agent signature) is out of scope โ€” a valid RSA SSH private key in OpenSSH format would need to be generated outside Node and checked in as a test fixture. The flag assertion catches the only branch we actually care about at this layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/unit/infra/ssh/ssh-agent-signer.test.ts | 87 ++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/test/unit/infra/ssh/ssh-agent-signer.test.ts b/test/unit/infra/ssh/ssh-agent-signer.test.ts index d8495b8a3..b1ef0ab45 100644 --- a/test/unit/infra/ssh/ssh-agent-signer.test.ts +++ b/test/unit/infra/ssh/ssh-agent-signer.test.ts @@ -377,6 +377,93 @@ describe('SshAgentSigner.sign()', () => { } }) + // Regression test for PR #435 review comment #25: the sign-request flags + // field is the only place where the RSA-via-agent code path diverges from + // the ed25519 path. Without this assertion, a refactor from + // `=== 'ssh-rsa'` to `=== 'rsa'` or `.startsWith('rsa-')` would go green + // across every other test โ€” because ed25519 uses flags=0 either way โ€” + // while RSA users silently sign with SHA-1 and downstream verification + // rejects the signature. RSA is the v1 recovery path for encrypted + // ~/.ssh/id_rsa users; breaking it silently means v1 ships with no RSA + // support for that population. + it('sets SSH_AGENT_RSA_SHA2_512 in flags for ssh-rsa keys (full agent round-trip)', async () => { + // Build a fake ssh-rsa wire-format public key blob. Content is + // opaque to this test โ€” only the fingerprint match to the agent + // identity matters for finding the signer, and the keyType on the + // .pub sidecar drives the flags branch inside sign(). + const rsaPubBlob = Buffer.concat([ + sshString('ssh-rsa'), + sshString(Buffer.alloc(3, 0x01)), // fake exponent + sshString(Buffer.alloc(128, 0x02)), // fake modulus + ]) + + const rsaKeyDir = mkdtempSync(join(tmpdir(), 'brv-rsa-agent-test-')) + const rsaKeyPath = join(rsaKeyDir, 'id_rsa') + writeFileSync(rsaKeyPath, 'fake rsa key material โ€” never parsed in this path', {mode: 0o600}) + writeFileSync( + `${rsaKeyPath}.pub`, + `ssh-rsa ${rsaPubBlob.toString('base64')} test@rsa`, + {mode: 0o644}, + ) + + // Mock agent that records the flags field from the first SIGN request. + let capturedFlags: number | undefined + const socketDir = mkdtempSync(join(tmpdir(), 'brv-rsa-mock-agent-')) + const socketPath = join(socketDir, 'agent.sock') + const server = net.createServer((conn) => { + const chunks: Buffer[] = [] + conn.on('data', (chunk) => { + chunks.push(chunk) + const acc = Buffer.concat(chunks) + if (acc.length < 4) return + const msgLen = acc.readUInt32BE(0) + if (acc.length < 4 + msgLen) return + const payload = acc.subarray(4, 4 + msgLen) + chunks.length = 0 + + if (payload[0] === SSH_AGENTC_REQUEST_IDENTITIES) { + const body = Buffer.concat([ + Buffer.from([SSH_AGENT_IDENTITIES_ANSWER]), + writeUInt32BE(1), + sshString(rsaPubBlob), + sshString('test@rsa'), + ]) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } else if (payload[0] === SSH_AGENTC_SIGN_REQUEST) { + // Parse: [type=13][uint32 len][blob][uint32 len][data][uint32 flags] + let offset = 1 + const blobLen = payload.readUInt32BE(offset) + offset += 4 + blobLen + const dataLen = payload.readUInt32BE(offset) + offset += 4 + dataLen + capturedFlags = payload.readUInt32BE(offset) + + // Return minimally valid signature envelope. + const sig = Buffer.concat([sshString('ssh-rsa'), sshString(Buffer.alloc(256, 0xbb))]) + const body = Buffer.concat([Buffer.from([SSH_AGENT_SIGN_RESPONSE]), sshString(sig)]) + conn.write(Buffer.concat([writeUInt32BE(body.length), body])) + } + }) + }) + server.listen(socketPath) + process.env.SSH_AUTH_SOCK = socketPath + + try { + const signer = await tryGetSshAgentSigner(rsaKeyPath) + expect(signer, 'tryGetSshAgentSigner must resolve with an RSA-capable signer').to.not.be.null + if (!signer) throw new Error('unreachable') + + await signer.sign('hello RSA') + + // Flag value comes from the SSH_AGENT_RSA_SHA2_512 constant (4) in + // ssh-agent-signer.ts โ€” pinned here so a refactor that drops the + // branch (e.g. `=== 'rsa'`) regresses immediately. + expect(capturedFlags, 'RSA signing must set SSH_AGENT_RSA_SHA2_512 flag').to.equal(4) + } finally { + server.close() + } + }) + // Regression test for PR #435 review comment #22: sign() used to blindly // return response.subarray(5, 5 + sigLen) without checking that sigLen // actually fits in the response buffer. A truncated agent response From 4b24422c368983bda3abae81764ab3f181c46238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 12:03:05 +0700 Subject: [PATCH 42/46] refactor(ssh): type predicates in place of as/! casts (ENG-2002 review #2+#3+#4+#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four unrelated-by-line-number but related-by-pattern cleanups, all CLAUDE.md "Avoid as Type / ! non-null assertions" hygiene. #2 key-parser.ts parseOpenSSHKey โ€” runtime check went through VALID_SSH_KEY_TYPES.has(string), then cast to SSHKeyType. Extract that into an exported isValidSSHKeyType predicate so the narrowing is visible to the compiler. #3 key-parser.ts parseSSHPrivateKey โ€” KeyObject.export has a string | Buffer overload; the cast masked Node's narrowing. Swap to Buffer.isBuffer and throw if the export ever returns a string. A naive Buffer.from(exported) would UTF-8-encode and silently corrupt DER. #4 ssh-agent-signer.ts tryGetSshAgentSigner โ€” narrow the return type of getPublicKeyMetadata to {keyType: SSHKeyType} (enforced via isValidSSHKeyType on the .pub sidecar branch, already guaranteed on the OpenSSH branch after #2). The call-site cast drops out naturally. Also normalises the sentinel from null to undefined along the way (addresses review #13 since both touch the same signature). #5 signing-key/add.ts โ€” `publicKey!` was papering over oclif's lack of an `asserts never` declaration on this.error(). Replace with an explicit `if (!publicKey) this.error(...)` narrow. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/oclif/commands/signing-key/add.ts | 11 ++++++-- src/server/infra/ssh/ssh-agent-signer.ts | 4 +-- src/shared/ssh/key-parser.ts | 32 ++++++++++++++++-------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/oclif/commands/signing-key/add.ts b/src/oclif/commands/signing-key/add.ts index 60ecb5c66..2bc3c2853 100644 --- a/src/oclif/commands/signing-key/add.ts +++ b/src/oclif/commands/signing-key/add.ts @@ -28,7 +28,7 @@ public static flags = { const {flags} = await this.parse(SigningKeyAdd) const keyPath = resolveHome(flags.key) - let publicKey: string + let publicKey: string | undefined let {title} = flags try { @@ -52,13 +52,20 @@ public static flags = { ) } + // Narrow explicitly: this.error() returns `never` but oclif's type + // declaration doesn't expose that, so without the guard TS treats + // publicKey as possibly undefined below. + if (!publicKey) { + this.error('Failed to determine public key material from key file') + } + if (!title) title = 'My SSH key' try { const response = await withDaemonRetry(async (client) => client.requestWithAck(VcEvents.SIGNING_KEY, { action: 'add', - publicKey: publicKey!, + publicKey, title, }), ) diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index 1d5f00074..1fac1982f 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -1,7 +1,7 @@ import {createHash} from 'node:crypto' import net from 'node:net' -import type {SSHKeyType, SSHSignatureResult} from './types.js' +import type {SSHSignatureResult} from './types.js' import {getPublicKeyMetadata} from './ssh-key-parser.js' import {SSHSIG_HASH_ALGORITHM, SSHSIG_MAGIC_PREAMBLE} from './sshsig-constants.js' @@ -267,7 +267,7 @@ export async function tryGetSshAgentSigner(keyPath: string): Promise id.fingerprint === parsed.fingerprint) if (!match) return null - return new SshAgentSigner(agent, match.blob, parsed.keyType as SSHKeyType) + return new SshAgentSigner(agent, match.blob, parsed.keyType) } catch { // Agent unavailable โ€” degrade gracefully to cache/file path return null diff --git a/src/shared/ssh/key-parser.ts b/src/shared/ssh/key-parser.ts index 2639f54f4..a896eecc1 100644 --- a/src/shared/ssh/key-parser.ts +++ b/src/shared/ssh/key-parser.ts @@ -44,6 +44,11 @@ const VALID_SSH_KEY_TYPES: ReadonlySet = new Set([ 'ssh-rsa', ]) +/** Type predicate โ€” true when `s` is one of the SSH key types we accept. */ +export function isValidSSHKeyType(s: string): s is SSHKeyType { + return VALID_SSH_KEY_TYPES.has(s) +} + /** Read a uint32 big-endian from a buffer at offset; returns [value, newOffset] */ function readUInt32(buf: Buffer, offset: number): [number, number] { return [buf.readUInt32BE(offset), offset + 4] @@ -106,13 +111,11 @@ function parseOpenSSHKey(raw: string): { // Read key type from public key blob to identify the key const [keyTypeBuf] = readSSHString(publicKeyBlob, 0) const keyTypeStr = keyTypeBuf.toString() - if (!VALID_SSH_KEY_TYPES.has(keyTypeStr)) { + if (!isValidSSHKeyType(keyTypeStr)) { throw new Error(`Unknown SSH key type: '${keyTypeStr}'`) } - const keyType = keyTypeStr as SSHKeyType - - return {cipherName, keyType, privateKeyBlob, publicKeyBlob} + return {cipherName, keyType: keyTypeStr, privateKeyBlob, publicKeyBlob} } /** @@ -361,7 +364,15 @@ export async function parseSSHPrivateKey( let keyType: SSHKeyType if (asymKeyType === 'ed25519') { - const derPub = publicKey.export({format: 'der', type: 'spki'}) as Buffer + // KeyObject.export has an overload returning string | Buffer depending on + // format. DER output is binary; Buffer.from(string) would silently + // UTF-8-encode and corrupt the DER bytes, so narrow via isBuffer. + const exported = publicKey.export({format: 'der', type: 'spki'}) + if (!Buffer.isBuffer(exported)) { + throw new TypeError('Expected Buffer from DER export of Ed25519 public key') + } + + const derPub = exported // Ed25519 SPKI DER = 12-byte ASN.1 header + 32-byte raw public key const rawPubBytes = derPub.subarray(12) @@ -449,15 +460,14 @@ export async function extractPublicKey(keyPath: string): Promise<{ * checking for a .pub file first, then attempting to parse an OpenSSH private key * (which contains the public key even if the private key is encrypted). */ -export async function getPublicKeyMetadata(keyPath: string): Promise { +export async function getPublicKeyMetadata(keyPath: string): Promise { const pubPath = keyPath.endsWith('.pub') ? keyPath : `${keyPath}.pub` try { const rawPub = await readFile(pubPath, 'utf8') const parts = rawPub.trim().split(' ') - if (parts.length >= 2) { - const keyType = parts[0] + if (parts.length >= 2 && isValidSSHKeyType(parts[0])) { const blob = Buffer.from(parts[1], 'base64') - return {fingerprint: computeFingerprint(blob), keyType} + return {fingerprint: computeFingerprint(blob), keyType: parts[0]} } } catch { // Ignore error, fallback to private key @@ -473,10 +483,10 @@ export async function getPublicKeyMetadata(keyPath: string): Promise Date: Wed, 22 Apr 2026 12:04:22 +0700 Subject: [PATCH 43/46] refactor: convert data-only DTO interfaces to type (ENG-2002 review #6+#7+#8+#9+#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md: "Prefer type for data-only shapes (DTOs, payloads, configs); prefer interface for behavioral contracts with method signatures." All of the following are plain data bags โ€” no method signatures on the shape itself: #6 SigningKeyResource (i-signing-key-service.ts) #7 ApiEnvelope, CreateSigningKeyData, ListSigningKeysData, RawSigningKeyResource (http-signing-key-service.ts) #8 SigningKeyHandlerDeps (signing-key-handler.ts) โ€” record of service refs, no methods on the deps struct itself #9 ParsedSSHKey, SSHSignatureResult, SSHKeyProbeResult, SSHKeyProbeResultFound (shared/ssh/types.ts) #10 SigningKeyItem (shared/transport/events/vc-events.ts) ISigningKeyService stays as an interface โ€” it's a behavioral contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/interfaces/services/i-signing-key-service.ts | 2 +- src/server/infra/iam/http-signing-key-service.ts | 8 ++++---- .../infra/transport/handlers/signing-key-handler.ts | 2 +- src/shared/ssh/types.ts | 8 ++++---- src/shared/transport/events/vc-events.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/server/core/interfaces/services/i-signing-key-service.ts b/src/server/core/interfaces/services/i-signing-key-service.ts index a1be67a67..e1c467ffe 100644 --- a/src/server/core/interfaces/services/i-signing-key-service.ts +++ b/src/server/core/interfaces/services/i-signing-key-service.ts @@ -1,6 +1,6 @@ // โ”€โ”€ DTOs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export interface SigningKeyResource { +export type SigningKeyResource = { createdAt: string fingerprint: string id: string diff --git a/src/server/infra/iam/http-signing-key-service.ts b/src/server/infra/iam/http-signing-key-service.ts index c9f73cc5e..ccd43e5c2 100644 --- a/src/server/infra/iam/http-signing-key-service.ts +++ b/src/server/infra/iam/http-signing-key-service.ts @@ -5,22 +5,22 @@ import {VcErrorCode} from '../../../shared/transport/events/vc-events.js' import {VcError} from '../../core/domain/errors/vc-error.js' // IAM API wraps all responses in {success, data} -interface ApiEnvelope { +type ApiEnvelope = { data: T success: boolean } // IAM API response wrappers (inside data envelope) -interface CreateSigningKeyData { +type CreateSigningKeyData = { signing_key: RawSigningKeyResource } -interface ListSigningKeysData { +type ListSigningKeysData = { signing_keys: RawSigningKeyResource[] } // IAM returns snake_case; map to camelCase -interface RawSigningKeyResource { +type RawSigningKeyResource = { created_at: string fingerprint: string id: string diff --git a/src/server/infra/transport/handlers/signing-key-handler.ts b/src/server/infra/transport/handlers/signing-key-handler.ts index 7e0156dc7..209b01fde 100644 --- a/src/server/infra/transport/handlers/signing-key-handler.ts +++ b/src/server/infra/transport/handlers/signing-key-handler.ts @@ -12,7 +12,7 @@ import {NotAuthenticatedError} from '../../../core/domain/errors/task-error.js' import {AuthenticatedHttpClient} from '../../http/authenticated-http-client.js' import {HttpSigningKeyService} from '../../iam/http-signing-key-service.js' -export interface SigningKeyHandlerDeps { +export type SigningKeyHandlerDeps = { iamBaseUrl: string /** Optional test seam: inject a pre-built service to bypass HTTP client construction. */ signingKeyService?: ISigningKeyService diff --git a/src/shared/ssh/types.ts b/src/shared/ssh/types.ts index a807db647..bbcf72a86 100644 --- a/src/shared/ssh/types.ts +++ b/src/shared/ssh/types.ts @@ -7,7 +7,7 @@ export type SSHKeyType = | 'ssh-ed25519' | 'ssh-rsa' -export interface ParsedSSHKey { +export type ParsedSSHKey = { /** SHA256 fingerprint โ€” used for display and matching with IAM */ fingerprint: string /** Key type identifier (e.g., 'ssh-ed25519') */ @@ -18,18 +18,18 @@ export interface ParsedSSHKey { publicKeyBlob: Buffer } -export interface SSHSignatureResult { +export type SSHSignatureResult = { /** Armored SSH signature (-----BEGIN SSH SIGNATURE----- ... -----END SSH SIGNATURE-----) */ armored: string /** Raw sshsig binary (before base64 armoring) */ raw: Buffer } -export interface SSHKeyProbeResult { +export type SSHKeyProbeResult = { exists: false } -export interface SSHKeyProbeResultFound { +export type SSHKeyProbeResultFound = { exists: true needsPassphrase: boolean /** True when the key is OpenSSH native format AND encrypted (bcrypt KDF + AES cipher). */ diff --git a/src/shared/transport/events/vc-events.ts b/src/shared/transport/events/vc-events.ts index 82f21cbcf..3133f9c97 100644 --- a/src/shared/transport/events/vc-events.ts +++ b/src/shared/transport/events/vc-events.ts @@ -159,7 +159,7 @@ export type IVcSigningKeyRequest = | {action: 'list'} | {action: 'remove'; keyId: string} -export interface SigningKeyItem { +export type SigningKeyItem = { createdAt: string fingerprint: string id: string From 57db09eccece6b855e44400081d491dc028cf7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 12:36:13 +0700 Subject: [PATCH 44/46] =?UTF-8?q?refactor(ssh):=20internal=20null=20sentin?= =?UTF-8?q?els=20=E2=86=92=20undefined=20(ENG-2002=20review=20#11+#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CLAUDE.md: "Default to undefined over null; reserve null only for external boundaries (storage, HTTP APIs) that force it." These are pure in-memory utilities with no external boundary: #11 SigningKeyCache.get โ€” Map-backed TTL wrapper. Switch the miss / expired sentinel to undefined, update the 10 call-site assertions in signing-key-cache.test.ts. #12 tryGetSshAgentSigner โ€” local factory for the agent-signer chain. Switch the "no signer available" sentinel to undefined and propagate through the `.catch(() => undefined)` in the inner getPublicKeyMetadata call. Update ssh-agent-signer.test.ts assertions. #13 (getPublicKeyMetadata) was handled in the earlier as/! refactor commit because it sits on the same signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/signing-key-cache.ts | 8 ++++---- src/server/infra/ssh/ssh-agent-signer.ts | 14 ++++++------- test/unit/infra/ssh/signing-key-cache.test.ts | 20 +++++++++---------- test/unit/infra/ssh/ssh-agent-signer.test.ts | 16 +++++++-------- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/server/infra/ssh/signing-key-cache.ts b/src/server/infra/ssh/signing-key-cache.ts index ef20545e2..1ae367128 100644 --- a/src/server/infra/ssh/signing-key-cache.ts +++ b/src/server/infra/ssh/signing-key-cache.ts @@ -61,16 +61,16 @@ private readonly ttlMs: number /** * Get a cached key for a (project, key) pair. - * Returns null if the entry does not exist or has expired. + * Returns undefined if the entry does not exist or has expired. */ - get(projectPath: string, keyPath: string): null | ParsedSSHKey { + get(projectPath: string, keyPath: string): ParsedSSHKey | undefined { const compositeKey = SigningKeyCache.composeKey(projectPath, keyPath) const entry = this.cache.get(compositeKey) - if (!entry) return null + if (!entry) return undefined if (Date.now() > entry.expiresAt) { this.cache.delete(compositeKey) - return null + return undefined } return entry.key diff --git a/src/server/infra/ssh/ssh-agent-signer.ts b/src/server/infra/ssh/ssh-agent-signer.ts index 1fac1982f..c87d5abf7 100644 --- a/src/server/infra/ssh/ssh-agent-signer.ts +++ b/src/server/infra/ssh/ssh-agent-signer.ts @@ -246,30 +246,30 @@ export class SshAgentSigner { * Try to get an SshAgentSigner for the key at the given path. * * Priority chain path A: connect to ssh-agent โ†’ find matching key โ†’ return signer. - * Returns null (non-throwing) if: + * Returns undefined (non-throwing) if: * - $SSH_AUTH_SOCK is not set * - Agent is unreachable * - The key at keyPath is not loaded in the agent */ -export async function tryGetSshAgentSigner(keyPath: string): Promise { +export async function tryGetSshAgentSigner(keyPath: string): Promise { const agentSocket = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? String.raw`\\.\pipe\openssh-ssh-agent` : undefined) - if (!agentSocket) return null + if (!agentSocket) return undefined try { const agent = new SshAgentClient(agentSocket) // Derive public key fingerprint from key file (reads only public key โ€” no passphrase needed) - const parsed = await getPublicKeyMetadata(keyPath).catch(() => null) - if (!parsed) return null + const parsed = await getPublicKeyMetadata(keyPath).catch(() => {}) + if (!parsed) return undefined // Find matching identity in agent const identities = await agent.listIdentities() const match = identities.find((id) => id.fingerprint === parsed.fingerprint) - if (!match) return null + if (!match) return undefined return new SshAgentSigner(agent, match.blob, parsed.keyType) } catch { // Agent unavailable โ€” degrade gracefully to cache/file path - return null + return undefined } } diff --git a/test/unit/infra/ssh/signing-key-cache.test.ts b/test/unit/infra/ssh/signing-key-cache.test.ts index 77590faff..7b31a2e32 100644 --- a/test/unit/infra/ssh/signing-key-cache.test.ts +++ b/test/unit/infra/ssh/signing-key-cache.test.ts @@ -21,7 +21,7 @@ describe('SigningKeyCache', () => { describe('get() / set()', () => { it('returns null for unknown key path', () => { const cache = new SigningKeyCache() - expect(cache.get(P1, '/nonexistent/key')).to.be.null + expect(cache.get(P1, '/nonexistent/key')).to.be.undefined }) it('returns stored key immediately after set()', () => { @@ -66,7 +66,7 @@ describe('SigningKeyCache', () => { it('get() from a different project returns null even with identical keyPath', () => { const cache = new SigningKeyCache() cache.set(P1, '/shared/path', makeFakeKey('p1')) - expect(cache.get(P2, '/shared/path')).to.be.null + expect(cache.get(P2, '/shared/path')).to.be.undefined }) it('invalidate() is project-scoped', () => { @@ -78,7 +78,7 @@ describe('SigningKeyCache', () => { cache.invalidate(P1, '/shared/path') - expect(cache.get(P1, '/shared/path')).to.be.null + expect(cache.get(P1, '/shared/path')).to.be.undefined expect(cache.get(P2, '/shared/path')).to.equal(keyInBeta) }) }) @@ -109,7 +109,7 @@ describe('SigningKeyCache', () => { await new Promise((resolve) => { setTimeout(resolve, 100) }) - expect(cache.get(P1, '/ttl/test')).to.be.null + expect(cache.get(P1, '/ttl/test')).to.be.undefined }) it('returns key before TTL expires', async function () { @@ -137,8 +137,8 @@ describe('SigningKeyCache', () => { cache.set(P1, '/new', makeFakeKey('new')) expect(cache.size).to.equal(1) - expect(cache.get(P1, '/old')).to.be.null - expect(cache.get(P1, '/new')).to.not.be.null + expect(cache.get(P1, '/old')).to.be.undefined + expect(cache.get(P1, '/new')).to.not.be.undefined }) }) @@ -148,7 +148,7 @@ describe('SigningKeyCache', () => { const key = makeFakeKey('to-clear') cache.set(P1, '/invalidate/me', key) cache.invalidate(P1, '/invalidate/me') - expect(cache.get(P1, '/invalidate/me')).to.be.null + expect(cache.get(P1, '/invalidate/me')).to.be.undefined }) it('does not affect other cached keys when invalidating one', () => { @@ -159,7 +159,7 @@ describe('SigningKeyCache', () => { cache.set(P1, '/remove', key2) cache.invalidate(P1, '/remove') expect(cache.get(P1, '/keep')).to.equal(key1) - expect(cache.get(P1, '/remove')).to.be.null + expect(cache.get(P1, '/remove')).to.be.undefined }) }) @@ -171,8 +171,8 @@ describe('SigningKeyCache', () => { cache.set(P2, '/c', makeFakeKey('c')) cache.invalidateAll() expect(cache.size).to.equal(0) - expect(cache.get(P1, '/a')).to.be.null - expect(cache.get(P2, '/c')).to.be.null + expect(cache.get(P1, '/a')).to.be.undefined + expect(cache.get(P2, '/c')).to.be.undefined }) }) }) diff --git a/test/unit/infra/ssh/ssh-agent-signer.test.ts b/test/unit/infra/ssh/ssh-agent-signer.test.ts index b1ef0ab45..8439e06a7 100644 --- a/test/unit/infra/ssh/ssh-agent-signer.test.ts +++ b/test/unit/infra/ssh/ssh-agent-signer.test.ts @@ -125,13 +125,13 @@ describe('tryGetSshAgentSigner()', () => { it('returns null when SSH_AUTH_SOCK is not set', async () => { delete process.env.SSH_AUTH_SOCK const result = await tryGetSshAgentSigner(keyPath) - expect(result).to.be.null + expect(result).to.be.undefined }) it('returns null when agent is unreachable', async () => { process.env.SSH_AUTH_SOCK = '/nonexistent/agent.sock' const result = await tryGetSshAgentSigner(keyPath) - expect(result).to.be.null + expect(result).to.be.undefined }) it('returns null when agent has no matching key', async () => { @@ -148,7 +148,7 @@ describe('tryGetSshAgentSigner()', () => { try { const result = await tryGetSshAgentSigner(keyPath) - expect(result).to.be.null + expect(result).to.be.undefined } finally { agent.cleanup() } @@ -169,7 +169,7 @@ describe('tryGetSshAgentSigner()', () => { try { const signer = await tryGetSshAgentSigner(keyPath) - expect(signer).to.not.be.null + expect(signer).to.not.be.undefined } finally { agent.cleanup() } @@ -210,7 +210,7 @@ describe('SshAgentSigner.sign()', () => { try { const signer = await tryGetSshAgentSigner(keyPath) - expect(signer).to.not.be.null + expect(signer).to.not.be.undefined const result = await signer!.sign('test commit payload') expect(result.armored).to.match(/^-----BEGIN SSH SIGNATURE-----/) @@ -302,7 +302,7 @@ describe('SshAgentSigner.sign()', () => { try { const signer = await tryGetSshAgentSigner(keyPath) - expect(signer).to.not.be.null + expect(signer).to.not.be.undefined await signer!.sign('test payload for magic check') // Per PROTOCOL.sshsig the signed-data structure is: @@ -450,7 +450,7 @@ describe('SshAgentSigner.sign()', () => { try { const signer = await tryGetSshAgentSigner(rsaKeyPath) - expect(signer, 'tryGetSshAgentSigner must resolve with an RSA-capable signer').to.not.be.null + expect(signer, 'tryGetSshAgentSigner must resolve with an RSA-capable signer').to.not.be.undefined if (!signer) throw new Error('unreachable') await signer.sign('hello RSA') @@ -512,7 +512,7 @@ describe('SshAgentSigner.sign()', () => { try { const signer = await tryGetSshAgentSigner(keyPath) - expect(signer, 'signer should be constructed').to.not.be.null + expect(signer, 'signer should be constructed').to.not.be.undefined if (!signer) throw new Error('unreachable') let caught: Error | undefined From 6605747b258bc23d45072faa694cd9f0f0f1b7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 12:36:47 +0700 Subject: [PATCH 45/46] refactor(ssh): extract TTL sweep for symmetric eviction (ENG-2002 review #15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep loop that evicts expired entries used to run only inside set(). Long-running daemons that are read-heavy (cache once, sign many) therefore never reclaimed memory for keys the user had stopped using โ€” get() only deleted the single entry it happened to touch. Extract `sweep(now)` as a private method and call it from both get() and set(). Ceiling growth was already practically bounded (projects ร— keys ร— ~1KB) so this is mostly a code-smell / symmetry fix rather than a memory leak. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/signing-key-cache.ts | 33 ++++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/server/infra/ssh/signing-key-cache.ts b/src/server/infra/ssh/signing-key-cache.ts index 1ae367128..bd0cfa630 100644 --- a/src/server/infra/ssh/signing-key-cache.ts +++ b/src/server/infra/ssh/signing-key-cache.ts @@ -64,16 +64,10 @@ private readonly ttlMs: number * Returns undefined if the entry does not exist or has expired. */ get(projectPath: string, keyPath: string): ParsedSSHKey | undefined { - const compositeKey = SigningKeyCache.composeKey(projectPath, keyPath) - const entry = this.cache.get(compositeKey) - if (!entry) return undefined - - if (Date.now() > entry.expiresAt) { - this.cache.delete(compositeKey) - return undefined - } - - return entry.key + const now = Date.now() + this.sweep(now) + const entry = this.cache.get(SigningKeyCache.composeKey(projectPath, keyPath)) + return entry && now <= entry.expiresAt ? entry.key : undefined } /** @@ -97,15 +91,22 @@ private readonly ttlMs: number */ set(projectPath: string, keyPath: string, key: ParsedSSHKey): void { const now = Date.now() - - // Sweep expired entries - for (const [path, entry] of this.cache) { - if (now > entry.expiresAt) this.cache.delete(path) - } - + this.sweep(now) this.cache.set(SigningKeyCache.composeKey(projectPath, keyPath), { expiresAt: now + this.ttlMs, key, }) } + + /** + * Evict every cache entry whose TTL has already elapsed at `now`. Called + * from both get() and set() so long-running daemons that are read-heavy + * (cache once, sign many) still reclaim memory for keys the user has + * stopped using. + */ + private sweep(now: number): void { + for (const [k, entry] of this.cache) { + if (now > entry.expiresAt) this.cache.delete(k) + } + } } From 4be8e74b1c69cc7d61ad52ba44ff5d702df498b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n?= Date: Wed, 22 Apr 2026 14:46:58 +0700 Subject: [PATCH 46/46] style(ssh): fix missing indent on SigningKeyCache.ttlMs field (ENG-2002 PR #435 nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy/paste artefact from 57db09e โ€” the ttlMs field lost its 2-space class-member indent. TypeScript compiled it fine but ESLint's indent rule would flag it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/infra/ssh/signing-key-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/infra/ssh/signing-key-cache.ts b/src/server/infra/ssh/signing-key-cache.ts index bd0cfa630..f14a00fd9 100644 --- a/src/server/infra/ssh/signing-key-cache.ts +++ b/src/server/infra/ssh/signing-key-cache.ts @@ -27,7 +27,7 @@ interface CacheEntry { */ export class SigningKeyCache { private readonly cache = new Map() -private readonly ttlMs: number + private readonly ttlMs: number /** * @param ttlMs - Cache TTL in milliseconds.