Skip to content

feat(vc): implement SSH commit signing (ENG-2002)#435

Open
hieuntg81 wants to merge 7 commits intomainfrom
feat/ENG-2002
Open

feat(vc): implement SSH commit signing (ENG-2002)#435
hieuntg81 wants to merge 7 commits intomainfrom
feat/ENG-2002

Conversation

@hieuntg81
Copy link
Copy Markdown
Collaborator

Summary

  • Problem: ByteRover commits have no cryptographic identity ? anyone can impersonate an author by setting user.name/user.email arbitrarily.
  • Why it matters: Signed commits give teams tamper-evident history and allow platforms (GitHub, GitLab) to display "Verified" badges, which is a baseline requirement for enterprise trust.
  • What changed:
    • New brv signing-key command group (add, list, remove) to manage SSH public keys registered in the Byterover IAM API.
    • brv vc commit gains --sign / --no-sign flags; prompts for passphrase interactively (up to 3 retries) on encrypted keys.
    • brv vc config supports two new keys (user.signingkey, commit.sign) and a --import-git-signing flag that reads local/global git config and imports SSH signing settings automatically.
    • New SSH infrastructure layer: pure-Node SSH key parser (no ssh-keygen subprocess), SSH agent client (Unix socket protocol), sshsig envelope signer, and an in-process signing-key fingerprint cache.
    • IAM HTTP service (HttpSigningKeyService) for signing-key CRUD against /api/v3/users/me/signing-keys.
    • New transport event vc:signing-key and error codes PASSPHRASE_REQUIRED, SIGNING_KEY_NOT_CONFIGURED, SIGNING_KEY_NOT_FOUND, INVALID_CONFIG_VALUE.
  • What did NOT change (scope boundary): Verification of signatures (reading/validating existing signed commits) is out of scope. No changes to push, fetch, or remote logic.

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Linked issues

  • Closes #ENG-2002
  • Related #

Root cause (bug fixes only, otherwise write N/A)

N/A

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only
  • Test file(s):
    • test/unit/infra/ssh/signing-key-cache.test.ts
    • test/unit/infra/ssh/ssh-agent-signer.test.ts
    • test/unit/infra/ssh/ssh-key-parser.test.ts
    • test/unit/infra/ssh/sshsig-signer.test.ts
    • test/unit/infra/transport/handlers/vc-handler.test.ts (extended)
  • Key scenario(s) covered:
    • SSH key parsing for ed25519, rsa, and ecdsa key types (with and without passphrase)
    • SSH agent identity listing and signing via Unix socket protocol
    • sshsig envelope construction and base64 armoring
    • Signing-key fingerprint cache hit/miss/expiry
    • vc-handler signing path: sign flag propagation, PASSPHRASE_REQUIRED error surfacing

User-visible changes

  • New commands:
    • brv signing-key add -k ~/.ssh/id_ed25519 [--title "My laptop"] ? registers SSH public key with Byterover.
    • brv signing-key list ? lists registered signing keys with fingerprints.
    • brv signing-key remove <id> ? removes a registered key.
  • brv vc commit -m "msg" --sign ? signs the commit; --no-sign overrides commit.sign=true in config.
  • brv vc config user.signingkey ~/.ssh/id_ed25519 ? sets the signing key path.
  • brv vc config commit.sign true ? enables auto-signing for all commits.
  • brv vc config --import-git-signing ? imports user.signingKey + gpg.format=ssh + commit.gpgSign from local or global git config.
  • On passphrase-protected keys the CLI now prompts interactively instead of failing with a raw error.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording

Attach at least one piece of evidence before merging.

Checklist

  • Tests added or updated and passing (npm test)
  • Lint passes (npm run lint)
  • Type check passes (npm run typecheck)
  • Build succeeds (npm run build)
  • Commits follow Conventional Commits format
  • Documentation updated (if applicable)
  • No breaking changes (or clearly documented above)
  • Branch is up to date with main

Risks and mitigations

  • Risk: Pure-Node SSH key parser may not support all key sub-formats (e.g., PKCS#8-wrapped RSA, newer OpenSSH format revisions).
    • Mitigation: Parser is used only for reading the public-key blob and detecting key type ? it does not perform signing. Actual signing is delegated to the SSH agent (for agent-held keys) or to sshsig-signer.ts which uses Node's native crypto.sign. Unsupported formats surface a clear error rather than silently producing a bad signature.
  • Risk: Passphrase is transmitted from CLI to daemon in the commit request payload over the local Socket.IO transport.
    • Mitigation: Transport is Unix-domain socket (loopback only, no network exposure). Passphrase is never persisted to disk or logged; it is used once then discarded.
  • Risk: SIGNING_KEY transport event handler is a new surface area that calls the IAM API on every signed commit.
    • Mitigation: SigningKeyCache caches fingerprints with a configurable TTL (default 60 s) to avoid per-commit IAM round-trips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 2m 46s —— View job


PR Review: feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Review SSH infrastructure layer (key parser, agent signer, sshsig)
  • Review transport/handler changes
  • Review CLI commands (signing-key, vc commit, vc config)
  • Review security concerns
  • Review test coverage
  • Post final review summary

Overall Score: 3 / 5

Solid foundation — the SSH agent path, sshsig envelope construction, fingerprint cache, and transport event design are all done well. However, there are several correctness/architecture issues that should be fixed before merge, and at least one critical feature (encrypted OpenSSH key decryption) is silently non-functional despite having a complete UI path for it.


Critical Issues

1. Architecture boundary violation: oclif/ imports from server/

src/oclif/commands/signing-key/add.ts:4 and src/oclif/commands/vc/config.ts:4 both import directly from server/infra/ssh/:

// add.ts:4, config.ts:4
import {parseSSHPrivateKey, resolveHome} from '../../../server/infra/ssh/index.js'

Per the documented architecture (CLAUDE.md): "All commands are daemon-routed: oclif/ and tui/ never import from server/." This boundary is ESLint-enforced. The SSH helpers needed client-side (like reading the public key blob for the IAM add call) should either be moved to shared/, or the command should delegate entirely to the daemon transport and receive back the public key in the response. Fix this →

2. Encrypted OpenSSH key decryption is silently not implemented

vc-handler.ts:558-582 shows a full retry loop for PASSPHRASE_REQUIRED, and vc/commit.ts prompts the user up to 3 times for a passphrase. But in ssh-key-parser.ts:259-263, when parseSSHPrivateKey is called with a passphrase for an encrypted OpenSSH-format key, it immediately throws:

throw new Error(
  'Encrypted OpenSSH private keys are not yet supported. ' +
  'Please use an unencrypted key or load it via ssh-agent.',
)

The user is prompted for a passphrase → sends it → gets the exact same error on every retry. The passphrase prompt loop is a dead end for OpenSSH-format encrypted keys. The PR description says "prompts for passphrase interactively (up to 3 retries) on encrypted keys" — this is misleading. Either: (a) implement bcrypt KDF + AES-256-CTR decryption, or (b) add a clear probe.needsPassphrase && isOpenSSHFormat check that skips the retry loop and tells the user to use ssh-agent instead. Fix this →

3. RSA signing algorithm is wrong in sshsig-signer.ts

sshsig-signer.ts:53:

const rawSignature = sign(null, signedData, key.privateKeyObject)

The code comment on line 51 correctly states "RSA: sign('sha512', data, key) — must specify hash", but the actual call always passes null. Node.js sign(null, ...) uses the key's default digest, which for RSA is SHA-1. This would produce an SHA-1-based RSA signature, which is wrong per the sshsig spec (rsa-sha2-512). Fix this →


Medium Issues

4. Surprising auto-sign default when only signingKey is set

vc-handler.ts:554:

const shouldSign = data.sign ?? config?.commitSign ?? config?.signingKey !== undefined

Due to operator precedence this evaluates as data.sign ?? config?.commitSign ?? (config?.signingKey !== undefined). A user who sets user.signingkey to test or prepare it — without explicitly enabling commit.sign — will find all their commits are silently signed. This contradicts git's behavior where setting user.signingKey alone doesn't auto-enable signing. Consider: config?.signingKey !== undefined should not be a fallback default. Fix this →

5. No authentication guard in SigningKeyHandler

signing-key-handler.ts:58-62:

private async createService(): Promise<ISigningKeyService> {
  const token = await this.tokenStore.load()
  const sessionKey = token?.sessionKey ?? ''  // ← empty string fallback
  const httpClient = new AuthenticatedHttpClient(sessionKey)
  return new HttpSigningKeyService(httpClient, this.iamBaseUrl)
}

When the user isn't logged in, an empty session key is used and the IAM API returns a generic HTTP 401 error. Compare with handleFetch, handlePush which throw NotAuthenticatedError proactively. Add an explicit auth check here. Fix this →

6. Missing test coverage for key new components

Per CLAUDE.md ("80% coverage minimum, critical paths must be covered"), the following have zero tests:

  • signing-key-handler.ts — the new transport event handler
  • http-signing-key-service.ts — IAM CRUD service
  • handleImportGitSigning() in vc-handler.ts — spawns execFile('git', ...), needs mocking

The existing vc-handler.test.ts only tests the signing path (flag propagation, PASSPHRASE_REQUIRED surfacing), which is good, but the IAM layer and import logic are untested.


Minor Issues

7. Deprecated Buffer.slice() in ssh-agent-signer.ts

Lines 62, 115 use .slice() on Buffer objects (deprecated since Node 17 in favor of .subarray()):

const blob = response.slice(offset, offset + blobLen)   // line 62
settle(() => resolve(accumulated.slice(4, 4 + responseLen)))  // line 115

Fix this →

8. Duplicate sshStr helper in ssh-key-parser.ts

The sshStr helper is defined twice — once inside opensshEd25519ToNodeKey (line 172) and again inside parseSSHPrivateKey (line 308). Extract it to module scope. Fix this →

9. TTL mismatch: code says 30 min, PR says 60 s

signing-key-cache.ts:26:

constructor(ttlMs: number = 30 * 60 * 1000) {  // 30 minutes

The PR description says "default 60 s". These should be consistent; 30-minute in-memory caching of a decrypted private key object is also a wider window than expected.

10. Sign indicator doesn't fire for config-based signing

vc/commit.ts:52:

const sigIndicator = sign === true ? ' 🔏' : ''

When sign is undefined but commit.sign=true is in config, the commit will be signed but no indicator is shown. The indicator should check the response or use shouldSign semantics.

11. Redundant key field sent in import-git-signing mode

vc/config.ts:77:

client.requestWithAck<IVcConfigResponse>(VcEvents.CONFIG, {importGitSigning: true, key: 'user.signingkey'})

IVcConfigRequest.key is required: VcConfigKey but is ignored server-side when importGitSigning: true. Consider making key optional in the type or documenting this behavior.


Positive Notes

  • The SSH agent integration path (priority A) is clean and gracefully degrades on agent unavailability.
  • sshsig envelope construction correctly implements the PROTOCOL.sshsig spec — magic bytes, namespace git, SHA-512 hash algorithm, and armoring width are all correct.
  • SigningKeyCache sweep-on-write prevents memory leaks without a background timer.
  • getPublicKeyMetadata correctly tries the .pub sidecar file first (no passphrase needed) — good UX for encrypted keys in the agent path.
  • Transport event design (IVcSigningKeyRequest discriminated union) is clean and extensible.
  • Test coverage for the SSH primitive layer (parser, agent, sshsig, cache) is solid.

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) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 3m 15s —— View job


PR Re-Review: feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Read updated files to verify previous review issues were addressed
  • Check critical fixes (architecture boundary, OpenSSH passphrase, RSA signing)
  • Check medium fixes (auto-sign default, auth guard, test coverage)
  • Check minor fixes
  • Post final review summary

Overall Score: 4.5 / 5

All 3 critical issues, all 3 medium issues, and all 5 minor issues from the prior review have been addressed. The PR is in strong shape. A few small observations remain, noted below.


Previously Critical — All Fixed ✅

1. Architecture boundary violationFixed

src/server/infra/ssh/ssh-key-parser.ts is now a thin re-export shell:

// ssh-key-parser.ts (server/infra/ssh)
export { parseSSHPrivateKey, probeSSHKey, resolveHome, ... }
  from '../../../shared/ssh/key-parser.js'

Both oclif/commands/signing-key/add.ts:4 and oclif/commands/vc/config.ts:4 now import from shared/ssh/index.js. Architecture boundary is clean.

2. OpenSSH encrypted key dead loopFixed

vc-handler.ts:1488-1496 now guards early:

if (probe.needsPassphrase && 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,
  )
}

probeSSHKey in shared/ssh/key-parser.ts:218-225 sets opensshEncrypted: true when cipherName !== 'none'. The passphrase retry loop is no longer a dead end.

3. RSA signing algorithmFixed

sshsig-signer.ts:53-60:

const isRsa = key.keyType === 'ssh-rsa'
const rawSignature = sign(isRsa ? 'sha512' : null, signedData, key.privateKeyObject)
const blobKeyType = isRsa ? 'rsa-sha2-512' : key.keyType

RSA now correctly uses SHA-512 and produces rsa-sha2-512 wrapped signatures per the sshsig spec.


Previously Medium — All Fixed ✅

4. Auto-sign defaultFixed

vc-handler.ts:555:

const shouldSign = data.sign ?? config?.commitSign ?? false

Setting user.signingkey alone no longer silently enables signing — matches git behaviour.

5. Auth guard in SigningKeyHandlerFixed

signing-key-handler.ts:59-60:

const token = await this.tokenStore.load()
if (!token?.isValid()) throw new NotAuthenticatedError()

No longer falls back to an empty session key.

6. Missing test coverageFixed

Two new test files added:

  • test/unit/infra/transport/handlers/signing-key-handler.test.ts — covers auth guard (missing token, expired token) and handler registration
  • test/unit/infra/iam/http-signing-key-service.test.ts — covers addKey, listKeys, removeKey, snake_case↔camelCase mapping, trailing-slash URL normalisation

Previously Minor — All Addressed ✅

# Issue Status
7 Buffer.slice() deprecated Fixed — all occurrences replaced with .subarray()
8 Duplicate sshStr helper Fixed — single module-scope definition in shared/ssh/key-parser.ts
9 TTL mismatch (code 30 min vs PR 60 s) Resolved — JSDoc in signing-key-cache.ts:33-36 explains the 30-min rationale; PR description was wrong
10 Sign indicator missing for config-based signing Fixed — now uses result.signed from server response (vc/commit.ts:52)
11 Redundant key field in import-git-signing mode Fixed — IVcConfigRequest is now a proper discriminated union; {importGitSigning: true} sent without a key field

Remaining Minor Observations

A. Dead code in parseSSHPrivateKey (shared/ssh/key-parser.ts:257-260)

if (cipherName !== 'none') {
  if (!passphrase) {
    throw new Error('Passphrase required for encrypted key')  // ← unreachable via resolveSigningKey
  }
  throw new Error('Encrypted OpenSSH private keys are not yet supported...')
}

Since resolveSigningKey now short-circuits on probe.opensshEncrypted before calling parseSSHPrivateKey, the if (!passphrase) branch here is dead code. The outer throw fires regardless. Safe to simplify to a single unconditional throw. Purely cosmetic. Fix this →

B. SigningKeyHandler action routing lacks injectable service seam

signing-key-handler.test.ts:157-163 acknowledges it cannot stub HttpSigningKeyService because the handler instantiates it internally via createService(). The test covers auth-guard + event registration, and http-signing-key-service.test.ts covers the HTTP layer separately — this is a reasonable split. For future improvement, consider accepting an optional signingKeyService?: ISigningKeyService injection in SigningKeyHandlerDeps (defaulting to the HTTP version) so the action-routing paths (add/list/remove) can be unit tested end-to-end in isolation. Fix this →

C. handleImportGitSigning still has no test coverage

vc-handler.ts:720-779 spawns execFile('git', ['config', '--get', key]). The updated vc-handler.test.ts tests the signing commit path but not the import path. The git subprocess needs mocking to cover: key-not-found, non-ssh gpg.format rejection, and successful import. Fix this →


Positive Notes

All the strengths from the original review still hold:

  • sshsig envelope construction is spec-compliant (magic, namespace, SHA-512 hash, armor width)
  • SSH agent integration gracefully degrades when agent is unavailable
  • SigningKeyCache sweep-on-write pattern prevents timer-based leaks
  • probeSSHKey correctly extracts public key from OpenSSH format without needing a passphrase
  • Transport event discriminated union (IVcConfigRequest) is now clean and type-safe
  • SSH primitive tests (parser, agent, sshsig, cache) remain solid

…(ENG-2002)

- 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) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 2m 2s —— View job


PR Re-Review (Round 3): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit 48ac694)
  • Verify remaining observations A, B, C from prior review
  • Read relevant files for any new issues
  • Post final review summary

Overall Score: 5 / 5

All 3 minor observations from the Round 2 review have been fully addressed. The PR is ready to merge.


Previously Minor — All Fixed ✅

A. Dead code in parseSSHPrivateKey (shared/ssh/key-parser.ts:257-260)Fixed

The unreachable if (!passphrase) branch was removed. parseSSHPrivateKey now unconditionally throws for encrypted OpenSSH keys with a clear comment explaining it is a safety-net for direct callers (since resolveSigningKey short-circuits on opensshEncrypted before this point):

if (cipherName !== 'none') {
  // 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...')
}

B. SigningKeyHandler injectable service seamFixed

SigningKeyHandlerDeps now has an optional signingKeyService?: ISigningKeyService field. createService() correctly enforces the auth guard first, then returns the injected service if provided:

private async createService(): Promise<ISigningKeyService> {
  const token = await this.tokenStore.load()
  if (!token?.isValid()) throw new NotAuthenticatedError()  // ← guard fires first
  if (this.injectedService) return this.injectedService     // ← seam used only if authed
  ...
}

Four new action-routing tests were added in signing-key-handler.test.ts covering add, list, remove, and confirming the auth guard still fires even when a service is injected.

C. handleImportGitSigning test coverageFixed

Three scenarios now covered in vc-handler.test.ts:

Scenario Assertion
Successful import (strips .pub, sets signingKey + commitSign) savedConfig.signingKey === keyPath, commitSign === true
Key path doesn't exist on disk throws SIGNING_KEY_NOT_FOUND
gpg.format is not ssh (e.g. gpg) throws INVALID_CONFIG_VALUE ← newly added in 48ac694

Also confirmed: SIGNING_KEY_NOT_SUPPORTED for encrypted OpenSSH keys is separately tested at vc-handler.test.ts:768.


Positive Notes

All prior strengths still hold, and the PR is now complete end-to-end:

  • sshsig envelope construction is spec-compliant (PROTOCOL.sshsig magic, namespace, SHA-512, armor width)
  • RSA signing correctly uses sha512 and produces rsa-sha2-512 wrapped signatures
  • SSH agent path gracefully degrades; encrypted OpenSSH keys have a clear, user-actionable error
  • SigningKeyCache sweep-on-write prevents timer-based leaks
  • Architecture boundary (oclifshared, not server) is clean
  • Auth guard fires proactively in SigningKeyHandler
  • shouldSign default is false — setting user.signingkey alone doesn't auto-enable signing
  • Test coverage is solid across the SSH primitive layer, IAM service, transport handlers, and config import

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 3m 58s —— View job


PR Re-Review (Round 4): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (merge from main)
  • Review any new or changed files
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round was a synchronize event from commit d90806b — a merge of main into feat/ENG-2002. The merged content is the "dream" feature (PR #436: proj/dreaming), which is completely orthogonal to SSH commit signing. The merge was clean with no conflicts.


Merge Impact Analysis

The 38 files brought in from main (dream executor, lock/state/log services, brv dream command, task-router enhancements) have zero overlap with the SSH signing feature files. Spot-checked the critical interaction points:

File Touched by SSH signing? Touched by dream merge? Conflict?
feature-handlers.ts ✅ (added SigningKeyHandler) None
transport/handlers/index.ts ✅ (added SigningKeyHandler export) None
shared/transport/events/vc-events.ts ✅ (new events/types) None
server/core/domain/transport/schemas.ts ✅ (added dream task type) None
server/infra/process/task-router.ts ✅ (pre-dispatch gates) None

All fixes from Rounds 1–3 remain intact after the merge:

  • Architecture boundary (oclifshared, not server) ✅
  • shouldSign defaults to false
  • RSA signing uses sha512 / rsa-sha2-512
  • Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED
  • SigningKeyHandler auth guard fires proactively ✅
  • Injectable service seam for testing ✅
  • handleImportGitSigning test coverage ✅

One Pre-Existing Minor Observation (carried over, not newly introduced)

signing-key add and vc config --import-git-signing fail for encrypted OpenSSH private keys without a .pub sidecar

src/oclif/commands/signing-key/add.ts:44 and src/oclif/commands/vc/config.ts:99:

const parsed = await parseSSHPrivateKey(keyPath)  // ← fails for encrypted keys

When the user passes an encrypted OpenSSH private key (the most common case) and there is no .pub sidecar, parseSSHPrivateKey throws "Encrypted OpenSSH private keys are not yet supported." But the only purpose here is to extract the public key — which does not require decryption. parseOpenSSHKey (used inside getPublicKeyMetadata) already extracts the unencrypted public key blob from the OpenSSH file header. Exporting a getPublicKeyBlob(keyPath) helper or using getPublicKeyMetadata + a separate blob-extraction path would eliminate this unnecessary failure for encrypted keys.

Impact: Low — the workaround is to pass the .pub sidecar file directly (documented in command examples: -k ~/.ssh/id_ed25519.pub). This issue was present before this merge and was not introduced or worsened by it. The commit-signing path is unaffected.


Summary

The merge from main introduces no regressions or conflicts in the SSH signing feature. The PR remains in the same excellent shape as after Round 3. Ready to merge.

…NG-2002)

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) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

Claude finished @hieuntg81's task in 2m 2s —— View job


PR Re-Review (Round 5): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit 65618ac)
  • Verify pre-existing observation from Round 4 (encrypted OpenSSH key without .pub sidecar)
  • Read relevant changed files
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round is commit 65618ac — "fix(vc): extract public key from OpenSSH header without decryption". This directly addresses the one remaining pre-existing observation carried over from Round 4. The PR is ready to merge.


Round 4 Pre-Existing Observation — Fixed ✅

signing-key add and vc config --import-git-signing fail for encrypted OpenSSH private keys without a .pub sidecarFixed

A new extractPublicKey() helper was added to shared/ssh/key-parser.ts with a clean priority chain:

// 1. .pub sidecar (also captures comment field — used as default title)
// 2. OpenSSH native format — public key lives in the unencrypted header,
//    so this works even when the private key is passphrase-protected
// 3. PEM/PKCS8 fallback — requires an unencrypted key (documented in JSDoc)
export async function extractPublicKey(keyPath: string): Promise<{
  comment?: string
  keyType: string
  publicKeyBlob: Buffer
}>

Both signing-key/add.ts:44 and vc/config.ts:100 now call extractPublicKey instead of parseSSHPrivateKey. Users can now run brv signing-key add -k ~/.ssh/id_ed25519 with an encrypted key and no sidecar — the public key is extracted from the file header without needing the passphrase.

Bonus UX improvement: extracted.comment is now used as the default key title (previously defaulted to a generic "My ${keyType} key" even when a comment was available).


Test Coverage ✅

Four new extractPublicKey() test cases in ssh-key-parser.test.ts:

Scenario Assertion
Encrypted OpenSSH key, no .pub sidecar keyType === 'ssh-ed25519', blob has length, comment === undefined
Encrypted key + .pub sidecar present Prefers sidecar; comment === 'user@laptop'; blob matches sidecar content
Unencrypted OpenSSH key, no sidecar keyType === 'ssh-ed25519', blob extracted
Non-existent file Throws

The makeEncryptedOpenSSHKey() fixture and the sshStr helper were correctly extracted to module scope (previously duplicated inline in two probeSSHKey tests) — clean-up included in the same commit.


One Cosmetic Observation (Non-blocking)

Assembled public key string omits the comment for private key inputs

In add.ts:46 and config.ts:102:

publicKey = `${extracted.keyType} ${b64}`   // ← no comment appended

The .pub-file path uses the full raw file content (including comment), while the private-key path omits it. The comment is used for the title field, just not included in the registered key string. This is functionally correct — SSH authorization only requires type blob — and is a trivial inconsistency not worth blocking on. Optional fix →


Summary

All 3 critical issues, all 3 medium issues, all 5 minor issues from Round 1, all 3 minor observations from Round 2, the pre-existing Round 4 observation — all addressed. The SSH commit signing feature is complete, architecturally sound, and well-tested.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 17, 2026

Claude finished @hieuntg81's task in 1m 20s —— View job


PR Re-Review (Round 6): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit bdd6414 — merge from main)
  • Review new files brought in from main for interaction with SSH signing feature
  • Verify all previous fixes remain intact
  • Post final review summary

Overall Score: 5 / 5

The trigger for this round was a synchronize event from commit bdd6414 — a merge of main into feat/ENG-2002. The merged content is the "query log" feature (PR #375: Proj/query log), which is completely orthogonal to SSH commit signing. The merge was clean with no conflicts.


Merge Impact Analysis

The 35 files brought in from main (query log entity, store, use cases, brv query-log commands, daemon wiring) have zero overlap with the SSH signing feature files. Spot-checked all critical interaction points:

File Touched by SSH signing? Touched by query log merge? Conflict?
feature-handlers.ts ✅ (added SigningKeyHandler) None
transport/handlers/index.ts ✅ (added SigningKeyHandler export) None
shared/transport/events/vc-events.ts ✅ (new signing-key events/types) None
server/core/domain/transport/schemas.ts ✅ (added task:queryResult event + schema) None
server/infra/daemon/brv-server.ts ✅ (added QueryLogHandler to lifecycleHooks) None

All fixes from Rounds 1–5 remain intact after the merge:

  • Architecture boundary (oclifshared/ssh, not server/infra/ssh) ✅
  • shouldSign defaults to false — setting user.signingkey alone does not auto-enable signing ✅
  • RSA signing uses sha512 / rsa-sha2-512 (sshsig-signer.ts:54,60) ✅
  • Encrypted OpenSSH keys short-circuit with SIGNING_KEY_NOT_SUPPORTED before passphrase loop (vc-handler.ts:1490) ✅
  • SigningKeyHandler auth guard fires proactively (signing-key-handler.ts:64) ✅
  • Injectable service seam for testing ✅
  • handleImportGitSigning test coverage ✅
  • extractPublicKey correctly extracts public key from encrypted OpenSSH files without passphrase (oclif/.../add.ts:44, oclif/.../config.ts:100) ✅

Summary

The merge from main introduces no regressions or conflicts in the SSH signing feature. The task:queryResult transport event added by the query log feature is entirely separate from the vc:signing-key event namespace. The PR remains in the same excellent shape as after Round 5. Ready to merge.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 17, 2026

Claude finished @hieuntg81's task in 4m 5s —— View job


PR Re-Review (Round 7): feat(vc): implement SSH commit signing (ENG-2002)

Tasks

  • Gather context and understand the request
  • Check what changed since last review (commit d4821d5)
  • Review new/modified files for correctness, security, and quality
  • Verify all previous fixes remain intact
  • Post final review summary

Overall Score: 4.5 / 5

Commit d4821d5 ("add support for SSH-agent signing, improved key parsing, and secure passphrase handling") brings several welcome improvements — Windows agent support, proper password masking, file permission hardening, and a docs page. However, it introduces one medium-severity UX regression in error handling that should be fixed before merge, plus a few smaller issues.


Medium Issues

1. isPassphraseError "unsupported" match is too broad — causes confusing passphrase prompt for non-ed25519 keys

shared/ssh/key-parser.ts:198:

return /bad decrypt|passphrase|bad password|interrupted or cancelled|unsupported/.test(msg)

Adding |unsupported was intended to catch OpenSSL "unsupported cipher" errors, but it also catches the developer-thrown errors from the new key-type restriction:

// probeSSHKey:221
throw new Error(`Unsupported OpenSSH key type: ${parsed.keyType}. Only ssh-ed25519 is supported natively. Please load this key into ssh-agent instead.`)
// probeSSHKey:236
throw new Error(`Unsupported PEM key type: ${pk.asymmetricKeyType}. Only ed25519 is supported natively. Please load this key into ssh-agent instead.`)

These errors contain "Unsupported" → isPassphraseError returns trueprobeSSHKey returns {exists: true, needsPassphrase: true} → CLI prompts the user for a passphrase → then parseSSHPrivateKey throws "Unsupported key type" anyway.

Resulting UX for an RSA/ECDSA key not loaded in the agent:

  1. brv vc commit --sign is run
  2. Agent check fails (key not in agent)
  3. probeSSHKey throws "Unsupported" → caught → returns needsPassphrase: true
  4. PASSPHRASE_REQUIRED error → CLI prompts "Enter SSH key passphrase:" (wrong prompt!)
  5. User enters passphrase → parseSSHPrivateKey throws "Unsupported key type"
  6. User is confused — they were asked for a passphrase, not told their key type is unsupported

Recommended fix: Remove |unsupported from isPassphraseError. For the new key-type restrictions, add a nativelySupported field to SSHKeyProbe and surface a clear VcError(SIGNING_KEY_NOT_SUPPORTED) from resolveSigningKey — not via the passphrase retry path. Fix this →


Minor Issues

2. --passphrase flag exposes passphrase in process list

src/oclif/commands/vc/commit.ts:19-21:

passphrase: Flags.string({
  description: 'SSH key passphrase (prefer BRV_SSH_PASSPHRASE env var)',
}),

Values passed as CLI flags are visible in ps aux and shell history. The description already says to prefer BRV_SSH_PASSPHRASE, but there's no security warning in the error output or docs. Consider either removing the flag entirely (env var only) or adding a prominent ⚠️ Security: note to the description field. Fix this →

3. Redundant user.signingKey (camelCase) in VcConfigKey type and VC_CONFIG_KEYS array

src/shared/transport/events/vc-events.ts:109-121:

export type VcConfigKey = 'commit.sign' | 'user.email' | 'user.name' | 'user.signingkey' | 'user.signingKey'
export const VC_CONFIG_KEYS: readonly string[] = ['user.name', 'user.email', 'user.signingkey', 'user.signingKey', 'commit.sign']

The server already normalises with data.key.toLowerCase() (vc-handler.ts:606), and isVcConfigKey already does case-insensitive matching. Having both user.signingkey and user.signingKey in the canonical type and array pollutes the API surface unnecessarily. Fix this →

4. Docs page is in Vietnamese only

docs/ssh-commit-signing.md is entirely in Vietnamese with no English version or fallback. If the project targets an international audience, the docs should be in English (or bilingual). Fix this →


Positive Notes

All fixes from Rounds 1–6 remain intact. The new commit also adds several genuine improvements:

  • password() instead of input() — passphrase input is now correctly masked. The old code used input() with a // @ts-expect-error hack.
  • TTY guard before promptingif (!process.stdin.isTTY) correctly errors out with an actionable message in non-interactive environments (CI, scripts).
  • Windows SSH agent named pipe\\.\pipe\openssh-ssh-agent fallback in tryGetSshAgentSigner makes the feature usable on Windows without SSH_AUTH_SOCK.
  • 0o600 on vc-git-config.json — config file now has restricted permissions; previously world-readable on Linux. Good security hardening.
  • cogit-url.ts regex ^ anchorparseUserFacingUrl now anchors the path match, preventing false matches on paths like /prefix/team/repo.git.
  • resolveHome Windows path fix — uses path.join(homedir(), keyPath.slice(2)) instead of raw string replacement, correctly handling ~\path on Windows.
  • Case-insensitive config key lookupFIELD_MAP[data.key.toLowerCase()] accepts both user.signingKey and user.signingkey.
  • Better hint on encrypted key confighandleConfig now checks the agent when setting a signing key path, giving a useful warning if the encrypted key isn't agent-loaded.

Summary

The PR is solid overall. Issue #1 (confusing passphrase prompt for unsupported key types) is the only one worth fixing before merge. Issues #2#4 are minor improvements that can go in this PR or a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant