Skip to content

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

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

feat(vc): implement SSH commit signing (ENG-2002)#435
hieuntg81 wants to merge 34 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.

bao-byterover
bao-byterover previously approved these changes Apr 20, 2026
Copy link
Copy Markdown
Collaborator

@bao-byterover bao-byterover left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please resolve, i also add a comment on ticket 2002.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (block):
I think this file should not exits, or if really needed, please make it 100% english.

Comment thread src/oclif/commands/vc/commit.ts Outdated

let pp: string
try {
pp = await password({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(block):

Oclif command imports @inquirer/prompts and calls password() — violates your 2026-04-17 rule "oclif non-interactive; all input via flags/args." TTY gate at :69 narrows but doesn't remove the interactive path.


// 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (block):
Please let claude code read the CLAUDE.md latest, it strict about return value undefined instead of null.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 20, 2026

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


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

Tasks

  • Gather context and understand blocking review comments
  • Fix blocking: translate docs/ssh-commit-signing.md to 100% English (added passphrase-protected key section)
  • Fix blocking: remove @inquirer/prompts interactive prompt from vc/commit.ts — oclif is now fully non-interactive; PASSPHRASE_REQUIRED errors with ssh-add / flag / env instructions
  • Fix blocking: use undefined instead of null in ssh-agent-signer.ts (CLAUDE.md rule); updated test assertions accordingly
  • Fix medium: removed |unsupported from isPassphraseError regex in key-parser.ts
  • Commit 9f25743 created — push requires manual approval (see below)

Changes in commit 9f25743

docs/ssh-commit-signing.md — Full English translation. Added "Passphrase-protected keys" section explaining ssh-add, BRV_SSH_PASSPHRASE, and --passphrase usage.

src/oclif/commands/vc/commit.ts — Removed @inquirer/prompts import and interactive password() retry loop (MAX_PASSPHRASE_RETRIES). The command is now fully non-interactive. Passphrase comes only from --passphrase flag or BRV_SSH_PASSPHRASE env var. On PASSPHRASE_REQUIRED, the command errors with:

SSH key passphrase required. Use --passphrase flag or set BRV_SSH_PASSPHRASE env var.
For passphrase-protected keys, loading the key into ssh-agent is recommended:
  ssh-add ~/.ssh/id_ed25519_signing

src/server/infra/ssh/ssh-agent-signer.ts — All return nullreturn undefined; return type changed from Promise<null | SshAgentSigner> to Promise<SshAgentSigner | undefined> per CLAUDE.md convention.

src/shared/ssh/key-parser.ts — Removed |unsupported from isPassphraseError regex. RSA/ECDSA "Unsupported key type" errors now propagate correctly to SIGNING_KEY_NOT_SUPPORTED instead of triggering a dead-end passphrase prompt.

test/unit/infra/ssh/ssh-agent-signer.test.ts — Updated 3 test descriptions and expect(result).to.be.nullexpect(result).to.be.undefined.


Note: The commit exists locally but the push was blocked by permission settings in this environment. Please approve the push or run git push origin feat/ENG-2002 from the workflow.

@bao-byterover bao-byterover self-requested a review April 20, 2026 10:11
@bao-byterover bao-byterover dismissed their stale review April 20, 2026 10:12

Wrong click

hieuntg81 and others added 9 commits April 20, 2026 17:36
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…2002 B1)

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) <noreply@anthropic.com>
…NG-2002 B2)

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) <noreply@anthropic.com>
…2 C2)

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) <noreply@anthropic.com>
…(ENG-2002 C1+M1)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
hieuntg81 and others added 9 commits April 20, 2026 20:08
…_OR_CANCELLED

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) <noreply@anthropic.com>
…odule

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) <noreply@anthropic.com>
…nce (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) <noreply@anthropic.com>
…review-2)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… Buffer (review-3)

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) <noreply@anthropic.com>
…eview-4)

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) <noreply@anthropic.com>
…G-2002 AC9-b/c)

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 <path> is not a valid SSH private
  key (unrecognised or malformed PEM / PKCS8 body)."
- ERR_OSSL_BAD_DECRYPT → "Wrong passphrase for SSH key at <path>."

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

github-actions bot commented Apr 20, 2026

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


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

Tasks

  • Gather context and understand the request
  • Check what changed since Round 8 review
  • Verify blocking issues from bao-byterover
  • Fix remaining nullundefined in ssh-agent-signer.ts + getPublicKeyMetadata
  • Update tests for null → undefined
  • Push blocked — requires manual approval (see note below)
  • Post final review summary

Changes since Round 8 (commits after d4821d5)

Blocking issues B1 and B2 from bao-byterover were already addressed by the developer:

  • 77464d7fix(oclif): remove interactive passphrase prompt from vc commit@inquirer/prompts gone, oclif is now fully non-interactive ✅
  • 608aa9fdocs(ssh): rewrite SSH signing guide in English with full coveragedocs/ssh-commit-signing.md is 100% English ✅

Blocking issue B3 (null instead of undefined) was NOT yet addressed. The developer's commits since d4821d5 only fixed the SSHSIG preamble constant and isPassphraseError regex — tryGetSshAgentSigner still returned null. Commit 48f6145 fixes this:

  • src/server/infra/ssh/ssh-agent-signer.tstryGetSshAgentSigner return type changed from Promise<null | SshAgentSigner> to Promise<SshAgentSigner | undefined>; all four return null sites changed to return undefined; .catch(() => null).catch(() => undefined)
  • src/shared/ssh/key-parser.tsgetPublicKeyMetadata return type changed from Promise<null | {}> to Promise<{} | undefined>; both return null sites changed to return undefined
  • test/unit/infra/ssh/ssh-agent-signer.test.ts — five test assertions updated from .to.be.null / .to.not.be.null to .to.be.undefined / .to.not.be.undefined; three it('returns null when ...') descriptions updated to it('returns undefined when ...')

Other notable improvements in the developer's commits:

  • c13397dformatCryptoError helper maps ERR_OSSL_BAD_DECRYPT → "Wrong passphrase for SSH key at …" and ERR_OSSL_UNSUPPORTED → "File at … is not a valid SSH private key…". Raw OpenSSL codes no longer surface to end users (AC9-b, AC9-c).
  • 36e2682 — Corrected signed-data structure to use 6-byte SSHSIG preamble (no null terminator) in ssh-agent-signer.ts as well — both signers now agree on the preamble.
  • c174c31 — Narrowed ERR_OSSL whitelist in isPassphraseError to exact codes, preventing false-positive passphrase prompts for malformed keys.

Note: Commit 48f6145 exists locally but the push was denied by repository permissions (403). Please run git push origin feat/ENG-2002 from the workflow or approve the push.

hieuntg81 and others added 6 commits April 21, 2026 00:21
…2002 §Signing Flow step 2)

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) <noreply@anthropic.com>
…ENG-2002 M4)

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) <noreply@anthropic.com>
…002 M5)

`brv signing-key remove <id>` 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 '<id>' 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) <noreply@anthropic.com>
…) (ENG-2002 M2)

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) <noreply@anthropic.com>
…002 M3)

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) <noreply@anthropic.com>
…rationale (ENG-2002 M6)

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

github-actions bot commented Apr 20, 2026

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


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

Tasks

  • Gather context and understand the request
  • Check what changed since Round 9 review (6 new commits)
  • Verify blocking issue B3: nullundefinedfound, fixed locally
  • Fix nullundefined in ssh-agent-signer.ts, key-parser.ts, signing-key-cache.ts
  • Update tests for null → undefined assertions
  • Commit fixes (local commit 0770731)
  • Post final review summary

Note on push: commit 0770731 exists locally but git push returned 403 — please run git push origin feat/ENG-2002 from the workflow, or approve the push in permissions.


Overall Score: 4.5 / 5

Six new commits since Round 9 bring several genuine improvements; one blocking issue (B3) remained unfixed by the developer and has been resolved in commit 0770731 (pending push). One dead-code observation in the new TUI commit flow is worth noting.


Blocking Issue Fixed in this Round

B3 — null returns instead of undefined (CLAUDE.md violation)Fixed by commit 0770731

Three files still returned null in violation of CLAUDE.md's "Default to undefined over null" rule:

File Function Change
src/server/infra/ssh/ssh-agent-signer.ts tryGetSshAgentSigner Promise<null | SshAgentSigner>Promise<SshAgentSigner | undefined>
src/shared/ssh/key-parser.ts getPublicKeyMetadata Promise<null | {...}>Promise<{...} | undefined>
src/server/infra/ssh/signing-key-cache.ts SigningKeyCache.get() null | ParsedSSHKeyParsedSSHKey | undefined

All return null call sites and test assertions (.to.be.null, .to.not.be.null, 'returns null when ...' titles) updated consistently.


New Commits — All Reviewed ✅

010e653 — feat(tui): Ink passphrase prompt

  • InlinePassword component — masked input (renders * per char, never writes raw value to Ink stream), Escape cancels, empty submission rejected. Clean.
  • vc-commit-flow-state.ts — pure reducer for committing → awaiting-passphrase → done state machine. Correct retry cap at MAX_PASSPHRASE_RETRIES = 3.
  • VcCommitFlow rewired to useReducer. When daemon returns PASSPHRASE_REQUIRED, InlinePassword renders and submitting fires a retry commit with passphrase in payload.
  • 13 unit tests cover all state transitions without spinning up an Ink tree — consistent with this project's convention. ✅

ce82e17 — refactor(ssh): scope SigningKeyCache by (projectPath, keyPath)

Project boundary isolation: two projects sharing a keyPath no longer share a cached crypto.KeyObject. Null-byte composite key is sound (\0 cannot appear in any POSIX/Windows path). ✅

2e4d228 — feat(ssh): add scrubPassphrase helper

Generic scrubPassphrase<T> redacts passphrase before logging. Correctly handles undefined/empty-string with an early return. ✅

0b4f7a5 — feat(signing-key): require --yes to confirm destructive remove

brv signing-key remove <id> now errors without --yes. Consistent with the oclif non-interactive rule. ✅

4adcbdc — refactor(ssh): consolidate SSHSIG_HASH_ALGORITHM constant

Extracted to sshsig-constants.ts with a four-point rationale comment explaining why sha512 is the only correct choice. ✅

8feaa2c — refactor(vc): drop camel-case user.signingKey

VcConfigKey union now contains only 'user.signingkey' (canonical lowercase). Camel-case is still accepted at runtime via .toLowerCase() in vc-handler.ts, but new code can't depend on it. Round 7 Minor #3 fully resolved. ✅


Minor Observations (Non-blocking)

A. Dead passphraseRef in vc-commit-flow.tsx

vc-commit-flow.tsx:73-74:

onSubmit={(pp) => {
  passphraseRef.current = pp   // ← written but never read
  dispatch({type: 'passphrase-submitted'})
  fireCommit(pp)               // ← pp passed directly
}}

passphraseRef.current is set to pp but fireCommit(pp) receives it directly in the same synchronous handler. No useEffect reads back from the ref. The ref write can be removed. Fix this →

B. --passphrase flag description could warn about process-list visibility

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

description: 'SSH key passphrase (or set BRV_SSH_PASSPHRASE env var)',

CLI flag values are visible in ps aux and shell history. The description should include a security note. (Raised in Round 7 Minor #2 — still not addressed.) Fix this →


Positive Notes

All strengths from prior rounds hold, plus the new commits add:

  • Passphrase retry loop is now correctly in the TUI (interactive) and cleanly absent from oclif (non-interactive). The separation is architecturally correct.
  • InlinePassword properly masks input — no raw secret in the Ink render tree.
  • vc-commit-flow-state.ts pure reducer is a clean split: testable transitions with no Ink dependency.
  • SigningKeyCache project scoping closes a subtle cross-project credential-sharing risk.
  • scrubPassphrase provides a safe logging boundary going forward.

hieuntg81 and others added 2 commits April 21, 2026 09:20
…ew A)

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

github-actions bot commented Apr 21, 2026

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


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

Tasks

  • Gather context and understand the request
  • Check what changed since Round 10 review
  • Verify Round 10 minor A fixed: dead passphraseRef — ✅ Fixed in db56ec0
  • Verify Round 10 minor B fixed: --passphrase flag warning — ✅ Fixed in 00931f8
  • Verify B3 null→undefined: still present — fixed locally, push blocked (see below)
  • Fix B3: nullundefined in all three files + update tests
  • Commit (local commit 9d3c64a) — push blocked, requires manual approval
  • Post final review summary

Overall Score: 5 / 5

Both minor observations from Round 10 have been fully addressed. The B3 nullundefined fix (carried since Round 9 with the push blocked each time) has been committed locally as 9d3c64aplease run git push origin feat/ENG-2002 from the workflow or approve the push.


Round 10 Observations — All Fixed ✅

A. Dead passphraseRef in vc-commit-flow.tsxFixed in db56ec0

The passphraseRef that was written but never read has been removed. The current vc-commit-flow.tsx only retains the legitimate fired ref (line 50) for guarding the first-commit useEffect. useRef import is still present for that use. ✅

B. --passphrase flag description missing security warningFixed in 00931f8

src/oclif/commands/vc/commit.ts:22:

description:
  'SSH key passphrase — WARNING: visible in process list and shell history. Prefer BRV_SSH_PASSPHRASE env var or ssh-agent.',

Clear, actionable, and correctly steers users toward the safer alternatives. ✅


Blocking Issue (B3) — Fixed Locally, Push Blocked

nullundefined in tryGetSshAgentSigner, getPublicKeyMetadata, SigningKeyCache.get()Commit 9d3c64a ready, push blocked (403)

CLAUDE.md requires undefined over null for all internal return values. Three files still violated this after Round 10 because the previous fix commit couldn't be pushed. Local commit 9d3c64a resolves all sites:

File Change
src/server/infra/ssh/ssh-agent-signer.ts Promise<null | SshAgentSigner>Promise<SshAgentSigner | undefined>; all return nullreturn undefined; .catch(() => null).catch(() => undefined); JSDoc updated
src/shared/ssh/key-parser.ts Promise<null | {…}>Promise<{…} | undefined>; both return nullreturn undefined
src/server/infra/ssh/signing-key-cache.ts null | ParsedSSHKeyParsedSSHKey | undefined; both return nullreturn undefined; JSDoc updated
test/unit/infra/ssh/ssh-agent-signer.test.ts All .to.be.null / .to.not.be.null.to.be.undefined / .to.not.be.undefined; 3 'returns null when …' descriptions updated
test/unit/infra/ssh/signing-key-cache.test.ts All .to.be.null / .to.not.be.null.to.be.undefined / .to.not.be.undefined; 3 test descriptions updated

All callers (vc-handler.ts:571,651) already use truthy/falsy checks (if (agentSigner), if (!parsed)) that work identically for undefined — no call-site changes needed.

Action required: git push origin feat/ENG-2002 from the workflow to land commit 9d3c64a.


Summary

Once 9d3c64a is pushed, the PR is complete. All 3 critical issues, 3 medium issues, 5 minor issues (Round 1), 3 observations (Round 2), and all follow-up observations through Round 11 have been resolved. The SSH commit signing feature is architecturally sound, spec-compliant, and well-tested.

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.

2 participants