feat(vc): implement SSH commit signing (ENG-2002)#435
feat(vc): implement SSH commit signing (ENG-2002)#435
Conversation
|
Claude finished @hieuntg81's task in 2m 46s —— View job PR Review: feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 3 / 5Solid foundation — the SSH agent path, Critical Issues1. Architecture boundary violation:
// 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: 2. Encrypted OpenSSH key decryption is silently not implemented
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 3. RSA signing algorithm is wrong in
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 Medium Issues4. Surprising auto-sign default when only
const shouldSign = data.sign ?? config?.commitSign ?? config?.signingKey !== undefinedDue to operator precedence this evaluates as 5. No authentication guard in
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 6. Missing test coverage for key new components Per CLAUDE.md ("80% coverage minimum, critical paths must be covered"), the following have zero tests:
The existing Minor Issues7. Deprecated Lines 62, 115 use const blob = response.slice(offset, offset + blobLen) // line 62
settle(() => resolve(accumulated.slice(4, 4 + responseLen))) // line 1158. Duplicate The 9. TTL mismatch: code says 30 min, PR says 60 s
constructor(ttlMs: number = 30 * 60 * 1000) { // 30 minutesThe 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
const sigIndicator = sign === true ? ' 🔏' : ''When 11. Redundant
client.requestWithAck<IVcConfigResponse>(VcEvents.CONFIG, {importGitSigning: true, key: 'user.signingkey'})
Positive Notes
|
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>
|
Claude finished @hieuntg81's task in 3m 15s —— View job PR Re-Review: feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 4.5 / 5All 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 violation — Fixed
// ssh-key-parser.ts (server/infra/ssh)
export { parseSSHPrivateKey, probeSSHKey, resolveHome, ... }
from '../../../shared/ssh/key-parser.js'Both 2. OpenSSH encrypted key dead loop — Fixed
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,
)
}
3. RSA signing algorithm — Fixed
const isRsa = key.keyType === 'ssh-rsa'
const rawSignature = sign(isRsa ? 'sha512' : null, signedData, key.privateKeyObject)
const blobKeyType = isRsa ? 'rsa-sha2-512' : key.keyTypeRSA now correctly uses SHA-512 and produces Previously Medium — All Fixed ✅4. Auto-sign default — Fixed
const shouldSign = data.sign ?? config?.commitSign ?? falseSetting 5. Auth guard in
const token = await this.tokenStore.load()
if (!token?.isValid()) throw new NotAuthenticatedError()No longer falls back to an empty session key. 6. Missing test coverage — Fixed Two new test files added:
Previously Minor — All Addressed ✅
Remaining Minor ObservationsA. Dead code in 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 B.
C.
Positive NotesAll the strengths from the original review still hold:
|
…(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>
|
Claude finished @hieuntg81's task in 2m 2s —— View job PR Re-Review (Round 3): feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 5 / 5All 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 The unreachable 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.
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 C. Three scenarios now covered in
Also confirmed: Positive NotesAll prior strengths still hold, and the PR is now complete end-to-end:
|
|
Claude finished @hieuntg81's task in 3m 58s —— View job PR Re-Review (Round 4): feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 5 / 5The trigger for this round was a Merge Impact AnalysisThe 38 files brought in from
All fixes from Rounds 1–3 remain intact after the merge:
One Pre-Existing Minor Observation (carried over, not newly introduced)
const parsed = await parseSSHPrivateKey(keyPath) // ← fails for encrypted keysWhen the user passes an encrypted OpenSSH private key (the most common case) and there is no Impact: Low — the workaround is to pass the SummaryThe merge from |
…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>
|
Claude finished @hieuntg81's task in 2m 2s —— View job PR Re-Review (Round 5): feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 5 / 5The trigger for this round is commit Round 4 Pre-Existing Observation — Fixed ✅
A new // 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 Bonus UX improvement: Test Coverage ✅Four new
The One Cosmetic Observation (Non-blocking)Assembled public key string omits the comment for private key inputs In publicKey = `${extracted.keyType} ${b64}` // ← no comment appendedThe SummaryAll 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. |
|
Claude finished @hieuntg81's task in 1m 20s —— View job PR Re-Review (Round 6): feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 5 / 5The trigger for this round was a Merge Impact AnalysisThe 35 files brought in from
All fixes from Rounds 1–5 remain intact after the merge:
SummaryThe merge from |
…cure passphrase handling for git commits
|
Claude finished @hieuntg81's task in 4m 5s —— View job PR Re-Review (Round 7): feat(vc): implement SSH commit signing (ENG-2002)Tasks
Overall Score: 4.5 / 5Commit Medium Issues1.
return /bad decrypt|passphrase|bad password|interrupted or cancelled|unsupported/.test(msg)Adding // 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" → Resulting UX for an RSA/ECDSA key not loaded in the agent:
Recommended fix: Remove Minor Issues2.
passphrase: Flags.string({
description: 'SSH key passphrase (prefer BRV_SSH_PASSPHRASE env var)',
}),Values passed as CLI flags are visible in 3. Redundant
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 4. Docs page is in Vietnamese only
Positive NotesAll fixes from Rounds 1–6 remain intact. The new commit also adds several genuine improvements:
SummaryThe 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. |
Summary
user.name/user.emailarbitrarily.brv signing-keycommand group (add,list,remove) to manage SSH public keys registered in the Byterover IAM API.brv vc commitgains--sign/--no-signflags; prompts for passphrase interactively (up to 3 retries) on encrypted keys.brv vc configsupports two new keys (user.signingkey,commit.sign) and a--import-git-signingflag that reads local/global git config and imports SSH signing settings automatically.ssh-keygensubprocess), SSH agent client (Unix socket protocol),sshsigenvelope signer, and an in-process signing-key fingerprint cache.HttpSigningKeyService) for signing-key CRUD against/api/v3/users/me/signing-keys.vc:signing-keyand error codesPASSPHRASE_REQUIRED,SIGNING_KEY_NOT_CONFIGURED,SIGNING_KEY_NOT_FOUND,INVALID_CONFIG_VALUE.Type of change
Scope (select all touched areas)
Linked issues
Root cause (bug fixes only, otherwise write
N/A)N/A
Test plan
test/unit/infra/ssh/signing-key-cache.test.tstest/unit/infra/ssh/ssh-agent-signer.test.tstest/unit/infra/ssh/ssh-key-parser.test.tstest/unit/infra/ssh/sshsig-signer.test.tstest/unit/infra/transport/handlers/vc-handler.test.ts(extended)ed25519,rsa, andecdsakey types (with and without passphrase)sshsigenvelope construction and base64 armoringvc-handlersigning path: sign flag propagation,PASSPHRASE_REQUIREDerror surfacingUser-visible changes
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-signoverridescommit.sign=truein 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? importsuser.signingKey+gpg.format=ssh+commit.gpgSignfrom local or global git config.Evidence
Checklist
npm test)npm run lint)npm run typecheck)npm run build)mainRisks and mitigations
sshsig-signer.tswhich uses Node's nativecrypto.sign. Unsupported formats surface a clear error rather than silently producing a bad signature.SIGNING_KEYtransport event handler is a new surface area that calls the IAM API on every signed commit.SigningKeyCachecaches fingerprints with a configurable TTL (default 60 s) to avoid per-commit IAM round-trips.