Skip to content
116 changes: 116 additions & 0 deletions docs/ssh-commit-signing.md
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.

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ý ✅
```
76 changes: 76 additions & 0 deletions src/oclif/commands/signing-key/add.ts
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))
}
}
}
45 changes: 45 additions & 0 deletions src/oclif/commands/signing-key/list.ts
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))
}
}
}
35 changes: 35 additions & 0 deletions src/oclif/commands/signing-key/remove.ts
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))
}
}
}
62 changes: 56 additions & 6 deletions src/oclif/commands/vc/commit.ts
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)
Expand All @@ -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({
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.

message: 'Enter SSH key passphrase:',
})
} catch {
this.error('Passphrase input cancelled.')
}

await this.runCommit(message, sign, pp, attempt + 1)
return
}

this.error(formatConnectionError(error))
}
}
Expand Down
Loading
Loading