-
Notifications
You must be signed in to change notification settings - Fork 443
feat(vc): implement SSH commit signing (ENG-2002) #435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hieuntg81
wants to merge
8
commits into
main
Choose a base branch
from
feat/ENG-2002
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
78fb480
feat(vc): implement SSH commit signing (ENG-2002)
hieuntg81 fb6667d
fix(vc): address PR #435 review issues in SSH commit signing (ENG-2002)
hieuntg81 48ac694
fix(vc): address remaining minor observations from PR #435 re-review …
hieuntg81 d90806b
Merge branch 'main' into feat/ENG-2002
hieuntg81 65618ac
fix(vc): extract public key from OpenSSH header without decryption (E…
hieuntg81 bdd6414
Merge branch 'main' into feat/ENG-2002
hieuntg81 d4821d5
feat: add support for SSH-agent signing, improved key parsing, and se…
hieuntg81 8880b5f
Merge branch 'main' into feat/ENG-2002
hieuntg81 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| # SSH Commit Signing | ||
|
|
||
| ByteRover hỗ trợ ký commit bằng SSH key. Khi được bật, mỗi commit sẽ được đính kèm chữ ký số và hiển thị trạng thái **Verified** trên ByteRover. | ||
|
|
||
| --- | ||
|
|
||
| ## 1. Tạo SSH key (nếu chưa có) | ||
|
|
||
| Khuyến nghị dùng Ed25519 — nhỏ gọn và bảo mật hơn RSA. | ||
|
|
||
| ```bash | ||
| ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519_signing | ||
| ``` | ||
|
|
||
| - `-C` là comment gắn vào key (thường là email). | ||
| - `-f` chỉ định tên file. Bạn có thể dùng key hiện có (`~/.ssh/id_ed25519`) nếu đã có. | ||
|
|
||
| Lệnh trên tạo ra 2 file: | ||
|
|
||
| | File | Vai trò | | ||
| |---|---| | ||
| | `~/.ssh/id_ed25519_signing` | Private key — **giữ bí mật** | | ||
| | `~/.ssh/id_ed25519_signing.pub` | Public key — đăng ký vào ByteRover | | ||
|
|
||
| --- | ||
|
|
||
| ## 2. Đăng ký public key lên ByteRover | ||
|
|
||
| ```bash | ||
| brv signing-key add --key ~/.ssh/id_ed25519_signing --title "My laptop" | ||
| ``` | ||
|
|
||
| - `--key` nhận cả private key (`.` không có đuôi) hoặc public key (`.pub`). | ||
| - `--title` là nhãn để phân biệt các thiết bị khác nhau (mặc định lấy comment trong key). | ||
|
|
||
| Kiểm tra key đã đăng ký: | ||
|
|
||
| ```bash | ||
| brv signing-key list | ||
| ``` | ||
|
|
||
| Kết quả trả về `Fingerprint` — dùng để đối chiếu khi cần xoá. | ||
|
|
||
| --- | ||
|
|
||
| ## 3. Cấu hình brv để dùng key ký | ||
|
|
||
| Trỏ brv đến private key: | ||
|
|
||
| ```bash | ||
| brv vc config user.signingkey ~/.ssh/id_ed25519_signing | ||
| ``` | ||
|
|
||
| Bật tự động ký tất cả commit: | ||
|
|
||
| ```bash | ||
| brv vc config commit.sign true | ||
| ``` | ||
|
|
||
| Từ đây mỗi `brv vc commit` sẽ tự động ký, không cần thêm flag. | ||
|
|
||
| --- | ||
|
|
||
| ## 4. Ký thủ công một commit (tùy chọn) | ||
|
|
||
| Nếu chưa bật `commit.sign`, vẫn có thể ký từng commit bằng flag: | ||
|
|
||
| ```bash | ||
| brv vc commit -m "feat: add feature" --sign | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 5. Kiểm tra cấu hình hiện tại | ||
|
|
||
| ```bash | ||
| brv vc config user.signingkey # xem đường dẫn key đang dùng | ||
| brv vc config commit.sign # xem trạng thái tự động ký | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Nếu đã cấu hình SSH signing trong git | ||
|
|
||
| Nếu bạn đã chạy `git config gpg.format ssh` và `git config user.signingKey ...`, brv có thể import trực tiếp: | ||
|
|
||
| ```bash | ||
| brv vc config --import-git-signing | ||
| ``` | ||
|
|
||
| Lệnh này đọc `user.signingKey` và `commit.gpgSign` từ git config hệ thống và áp vào brv — không cần set thủ công. | ||
|
|
||
| --- | ||
|
|
||
| ## Xoá key không còn dùng | ||
|
|
||
| ```bash | ||
| brv signing-key list # lấy key ID | ||
| brv signing-key remove <key-id> # xoá | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Tóm tắt luồng thiết lập | ||
|
|
||
| ``` | ||
| ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_signing | ||
| ↓ | ||
| brv signing-key add --key ~/.ssh/id_ed25519_signing --title "My laptop" | ||
| ↓ | ||
| brv vc config user.signingkey ~/.ssh/id_ed25519_signing | ||
| ↓ | ||
| brv vc config commit.sign true | ||
| ↓ | ||
| brv vc commit -m "..." → tự động ký ✅ | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import {Command, Flags} from '@oclif/core' | ||
| import {readFileSync} from 'node:fs' | ||
|
|
||
| import {extractPublicKey, resolveHome} from '../../../shared/ssh/index.js' | ||
| import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' | ||
| import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' | ||
|
|
||
| export default class SigningKeyAdd extends Command { | ||
| public static description = 'Add an SSH public key to your Byterover account for commit signing' | ||
| public static examples = [ | ||
| '<%= config.bin %> <%= command.id %> --key ~/.ssh/id_ed25519 --title "Dev laptop"', | ||
| '<%= config.bin %> <%= command.id %> -k ~/.ssh/id_ed25519.pub', | ||
| ] | ||
| public static flags = { | ||
| key: Flags.string({ | ||
| char: 'k', | ||
| description: | ||
| 'Path to the SSH private key (used to derive the public key) or a .pub file', | ||
| required: true, | ||
| }), | ||
| title: Flags.string({ | ||
| char: 't', | ||
| description: 'Human-readable label for the key (defaults to the key comment)', | ||
| }), | ||
| } | ||
|
|
||
| public async run(): Promise<void> { | ||
| const {flags} = await this.parse(SigningKeyAdd) | ||
| const keyPath = resolveHome(flags.key) | ||
|
|
||
| let publicKey: string | ||
| let {title} = flags | ||
|
|
||
| try { | ||
| if (keyPath.endsWith('.pub')) { | ||
| // Public key file — read directly | ||
| const raw = readFileSync(keyPath, 'utf8').trim() | ||
| publicKey = raw | ||
| // Extract comment as default title (third field in authorized_keys format) | ||
| const parts = raw.split(' ') | ||
| if (!title && parts.length >= 3) title = parts.slice(2).join(' ') | ||
| } else { | ||
| // Private key file — extract public key without decryption (works for encrypted keys too) | ||
| const extracted = await extractPublicKey(keyPath) | ||
| const b64 = extracted.publicKeyBlob.toString('base64') | ||
| publicKey = `${extracted.keyType} ${b64}` | ||
| if (!title) title = extracted.comment ?? `My ${extracted.keyType} key` | ||
| } | ||
| } catch (error) { | ||
| this.error( | ||
| `Failed to read key file: ${error instanceof Error ? error.message : String(error)}`, | ||
| ) | ||
| } | ||
|
|
||
| if (!title) title = 'My SSH key' | ||
|
|
||
| try { | ||
| const response = await withDaemonRetry(async (client) => | ||
| client.requestWithAck<IVcSigningKeyResponse>(VcEvents.SIGNING_KEY, { | ||
| action: 'add', | ||
| publicKey: publicKey!, | ||
| title, | ||
| }), | ||
| ) | ||
|
|
||
| if (response.action === 'add' && response.key) { | ||
| this.log('✅ Signing key added successfully') | ||
| this.log(` Title: ${response.key.title}`) | ||
| this.log(` Fingerprint: ${response.key.fingerprint}`) | ||
| this.log(` Type: ${response.key.keyType}`) | ||
| } | ||
| } catch (error) { | ||
| this.error(formatConnectionError(error)) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import {Command} from '@oclif/core' | ||
|
|
||
| import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' | ||
| import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' | ||
|
|
||
| export default class SigningKeyList extends Command { | ||
| public static description = 'List SSH signing keys registered on your Byterover account' | ||
| public static examples = ['<%= config.bin %> <%= command.id %>'] | ||
|
|
||
| public async run(): Promise<void> { | ||
| try { | ||
| const response = await withDaemonRetry(async (client) => | ||
| client.requestWithAck<IVcSigningKeyResponse>(VcEvents.SIGNING_KEY, {action: 'list'}), | ||
| ) | ||
|
|
||
| if (response.action !== 'list' || !response.keys) { | ||
| this.error('Unexpected response from daemon') | ||
| } | ||
|
|
||
| const {keys} = response | ||
|
|
||
| if (keys.length === 0) { | ||
| this.log('No signing keys registered.') | ||
| this.log(' Run: brv signing-key add --key ~/.ssh/id_ed25519') | ||
| return | ||
| } | ||
|
|
||
| this.log(`\nSigning keys (${keys.length}):\n`) | ||
| for (const key of keys) { | ||
| const lastUsed = key.lastUsedAt | ||
| ? `Last used: ${new Date(key.lastUsedAt).toLocaleDateString()}` | ||
| : 'Never used' | ||
| this.log(` [${key.id}]`) | ||
| this.log(` Title: ${key.title}`) | ||
| this.log(` Fingerprint: ${key.fingerprint}`) | ||
| this.log(` Type: ${key.keyType}`) | ||
| this.log(` ${lastUsed}`) | ||
| this.log(` Added: ${new Date(key.createdAt).toLocaleDateString()}`) | ||
| this.log('') | ||
| } | ||
| } catch (error) { | ||
| this.error(formatConnectionError(error)) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import {Args, Command} from '@oclif/core' | ||
|
|
||
| import {type IVcSigningKeyResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' | ||
| import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' | ||
|
|
||
| export default class SigningKeyRemove extends Command { | ||
| public static args = { | ||
| id: Args.string({ | ||
| description: 'Key ID to remove (from brv signing-key list)', | ||
| required: true, | ||
| }), | ||
| } | ||
| public static description = 'Remove an SSH signing key from your Byterover account' | ||
| public static examples = [ | ||
| '<%= config.bin %> <%= command.id %> <key-id>', | ||
| '# Get key ID from: brv signing-key list', | ||
| ] | ||
|
|
||
| public async run(): Promise<void> { | ||
| const {args} = await this.parse(SigningKeyRemove) | ||
|
|
||
| try { | ||
| await withDaemonRetry(async (client) => | ||
| client.requestWithAck<IVcSigningKeyResponse>(VcEvents.SIGNING_KEY, { | ||
| action: 'remove', | ||
| keyId: args.id, | ||
| }), | ||
| ) | ||
|
|
||
| this.log(`✅ Signing key removed: ${args.id}`) | ||
| } catch (error) { | ||
| this.error(formatConnectionError(error)) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,31 @@ | ||
| import {password} from '@inquirer/prompts' | ||
| import {Command, Flags} from '@oclif/core' | ||
|
|
||
| import {type IVcCommitResponse, VcEvents} from '../../../shared/transport/events/vc-events.js' | ||
| import {type IVcCommitRequest, type IVcCommitResponse, VcErrorCode, VcEvents} from '../../../shared/transport/events/vc-events.js' | ||
| import {formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js' | ||
|
|
||
| export default class VcCommit extends Command { | ||
| public static description = 'Save staged changes as a commit' | ||
| public static examples = ['<%= config.bin %> <%= command.id %> -m "Add project architecture notes"'] | ||
| public static flags = { | ||
| public static examples = [ | ||
| '<%= config.bin %> <%= command.id %> -m "Add project architecture notes"', | ||
| '<%= config.bin %> <%= command.id %> -m "Signed commit" --sign', | ||
| '<%= config.bin %> <%= command.id %> -m "Unsigned commit" --no-sign', | ||
| ] | ||
| public static flags = { | ||
| message: Flags.string({ | ||
| char: 'm', | ||
| description: 'Commit message', | ||
| }), | ||
| passphrase: Flags.string({ | ||
| description: 'SSH key passphrase (prefer BRV_SSH_PASSPHRASE env var)', | ||
| }), | ||
| sign: Flags.boolean({ | ||
| allowNo: true, | ||
| description: 'Sign the commit with your configured SSH key. Use --no-sign to override commit.sign=true.', | ||
| }), | ||
| } | ||
| public static strict = false | ||
| private static readonly MAX_PASSPHRASE_RETRIES = 3 | ||
| public static strict = false | ||
|
|
||
| public async run(): Promise<void> { | ||
| const {argv, flags} = await this.parse(VcCommit) | ||
|
|
@@ -26,13 +39,50 @@ export default class VcCommit extends Command { | |
| this.error('Usage: brv vc commit -m "<message>"') | ||
| } | ||
|
|
||
| const {sign} = flags | ||
| const pp = flags.passphrase ?? process.env.BRV_SSH_PASSPHRASE | ||
|
|
||
| await this.runCommit(message, sign, pp) | ||
| } | ||
|
|
||
| private async runCommit(message: string, sign: boolean | undefined, passphrase?: string, attempt: number = 0): Promise<void> { | ||
| const payload: IVcCommitRequest = {message, ...(sign === undefined ? {} : {sign}), ...(passphrase ? {passphrase} : {})} | ||
|
|
||
| try { | ||
| const result = await withDaemonRetry(async (client) => | ||
| client.requestWithAck<IVcCommitResponse>(VcEvents.COMMIT, {message}), | ||
| client.requestWithAck<IVcCommitResponse>(VcEvents.COMMIT, payload), | ||
| ) | ||
|
|
||
| this.log(`[${result.sha.slice(0, 7)}] ${result.message}`) | ||
| const sigIndicator = result.signed ? ' 🔏' : '' | ||
| this.log(`[${result.sha.slice(0, 7)}] ${result.message}${sigIndicator}`) | ||
| } catch (error) { | ||
| // Passphrase required — prompt and retry (capped) | ||
| if ( | ||
| error instanceof Error && | ||
| 'code' in error && | ||
| (error as {code: string}).code === VcErrorCode.PASSPHRASE_REQUIRED | ||
| ) { | ||
| if (attempt >= VcCommit.MAX_PASSPHRASE_RETRIES) { | ||
| this.error(`Too many failed passphrase attempts (${VcCommit.MAX_PASSPHRASE_RETRIES}).`) | ||
| } | ||
|
|
||
| if (!process.stdin.isTTY) { | ||
| this.error('Passphrase required but no TTY available. Set BRV_SSH_PASSPHRASE env var or use --passphrase flag.') | ||
| } | ||
|
|
||
| let pp: string | ||
| try { | ||
| pp = await password({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| message: 'Enter SSH key passphrase:', | ||
| }) | ||
| } catch { | ||
| this.error('Passphrase input cancelled.') | ||
| } | ||
|
|
||
| await this.runCommit(message, sign, pp, attempt + 1) | ||
| return | ||
| } | ||
|
|
||
| this.error(formatConnectionError(error)) | ||
| } | ||
| } | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.