Skip to content

Commit 6eab940

Browse files
committed
feat: add key management (export/import/regen) and rebind commands
- Add 'opencode-sync key export/import/regen' for encryption key management - Add 'opencode-sync rebind <url>' to change remote URL - Add key management and rebind to interactive menu - Fix misleading 'save public key' message (should be private key) - Update README with encryption key management docs
1 parent d666d0b commit 6eab940

File tree

4 files changed

+367
-16
lines changed

4 files changed

+367
-16
lines changed

README.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ For scripting or power users:
9696
| `opencode-sync push` | Push local changes |
9797
| `opencode-sync status` | Show sync status |
9898
| `opencode-sync diff` | Show differences |
99+
| `opencode-sync rebind <url>` | Change remote repository URL |
99100
| `opencode-sync doctor` | Diagnose issues |
100101
| `opencode-sync config` | Manage configuration |
102+
| `opencode-sync key` | Manage encryption keys |
101103

102104
## Requirements
103105

@@ -167,14 +169,45 @@ Config file location:
167169

168170
## Encryption
169171

170-
opencode-sync uses [age](https://age-encryption.org/) for encryption.
172+
opencode-sync uses [age](https://age-encryption.org/) for encryption of sensitive files (auth tokens).
171173

172-
```bash
173-
# Enable encryption during setup, or manually:
174-
opencode-sync config
175-
```
174+
### Key Management
176175

177-
Your encryption key is stored locally and **never synced**. When setting up a new machine, you'll need to transfer the key file securely.
176+
| Command | Description |
177+
|---------|-------------|
178+
| `opencode-sync key export` | Display private key for backup |
179+
| `opencode-sync key import <key>` | Import key from backup |
180+
| `opencode-sync key regen` | Generate new key (⚠️ old encrypted data lost) |
181+
182+
### Setting Up a New Machine
183+
184+
1. On your **existing machine**, export your key:
185+
```bash
186+
opencode-sync key export
187+
```
188+
2. Save the key in your password manager (e.g., 1Password)
189+
190+
3. On your **new machine**, import the key before cloning:
191+
```bash
192+
opencode-sync key import "AGE-SECRET-KEY-1..."
193+
opencode-sync clone git@github.com:user/opencode-config.git
194+
```
195+
196+
### Lost Your Key?
197+
198+
If you lose your private key, encrypted data (auth tokens) cannot be recovered. However:
199+
- Your configs, agents, and other non-sensitive files are **not encrypted** and still accessible
200+
- You can regenerate a new key and re-authenticate:
201+
```bash
202+
opencode-sync key regen
203+
opencode-sync push # Push with new encryption
204+
```
205+
206+
### Important Notes
207+
208+
- Your private key is stored at `~/.config/opencode-sync/age.key`
209+
- The key is **never synced** to the remote repository
210+
- **Back up your key** to a password manager immediately after setup
178211

179212
## Development
180213

internal/cli/commands.go

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,82 @@ Examples:
204204
},
205205
}
206206

207+
var keyCmd = &cobra.Command{
208+
Use: "key",
209+
Short: "Manage encryption keys",
210+
Long: `Manage encryption keys for secure syncing of auth tokens.`,
211+
RunE: func(cmd *cobra.Command, args []string) error {
212+
return runKeyExport()
213+
},
214+
}
215+
216+
var keyExportCmd = &cobra.Command{
217+
Use: "export",
218+
Short: "Export private key for backup",
219+
Long: `Export your private encryption key.
220+
221+
IMPORTANT: Store this key securely (e.g., password manager).
222+
Without it, encrypted data (auth tokens) cannot be recovered.`,
223+
RunE: func(cmd *cobra.Command, args []string) error {
224+
return runKeyExport()
225+
},
226+
}
227+
228+
var keyImportCmd = &cobra.Command{
229+
Use: "import <key>",
230+
Short: "Import a private key",
231+
Long: `Import a private key from backup.
232+
233+
Use this when setting up a new machine to decrypt existing auth tokens.
234+
235+
Example:
236+
opencode-sync key import "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ"`,
237+
Args: cobra.ExactArgs(1),
238+
RunE: func(cmd *cobra.Command, args []string) error {
239+
return runKeyImport(args[0])
240+
},
241+
}
242+
243+
var keyRegenCmd = &cobra.Command{
244+
Use: "regen",
245+
Short: "Regenerate encryption key",
246+
Long: `Generate a new encryption key, replacing the existing one.
247+
248+
WARNING: Previously encrypted data will become unrecoverable!
249+
Only use this if you've lost your key and need to start fresh.`,
250+
RunE: func(cmd *cobra.Command, args []string) error {
251+
return runKeyRegen()
252+
},
253+
}
254+
255+
var rebindCmd = &cobra.Command{
256+
Use: "rebind <url>",
257+
Short: "Change the remote repository URL",
258+
Long: `Change the remote repository URL without reinitializing.
259+
260+
This updates the remote URL for an existing sync repository.
261+
Useful when migrating to a new git host or changing repo location.
262+
263+
Examples:
264+
opencode-sync rebind git@github.com:user/new-repo.git
265+
opencode-sync rebind https://github.com/user/new-repo.git`,
266+
Args: cobra.ExactArgs(1),
267+
RunE: func(cmd *cobra.Command, args []string) error {
268+
return runRebind(args[0])
269+
},
270+
}
271+
207272
func init() {
208273
// Add config subcommands
209274
configCmd.AddCommand(configShowCmd)
210275
configCmd.AddCommand(configPathCmd)
211276
configCmd.AddCommand(configEditCmd)
212277
configCmd.AddCommand(configSetCmd)
278+
279+
// Add key subcommands
280+
keyCmd.AddCommand(keyExportCmd)
281+
keyCmd.AddCommand(keyImportCmd)
282+
keyCmd.AddCommand(keyRegenCmd)
213283
}
214284

215285
// Command implementations
@@ -1027,11 +1097,184 @@ func runClone(repoURL string) error {
10271097
return nil
10281098
}
10291099

1030-
// getHostname returns the hostname or "unknown"
10311100
func getHostname() string {
10321101
hostname, err := os.Hostname()
10331102
if err != nil {
10341103
return "unknown"
10351104
}
10361105
return hostname
10371106
}
1107+
1108+
func runKeyExport() error {
1109+
p, err := paths.Get()
1110+
if err != nil {
1111+
return fmt.Errorf("failed to get paths: %w", err)
1112+
}
1113+
1114+
keyFile := p.KeyFile()
1115+
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
1116+
return fmt.Errorf("no encryption key found. Run 'opencode-sync setup' with encryption enabled first")
1117+
}
1118+
1119+
privateKey, err := crypto.LoadKeyFromFile(keyFile)
1120+
if err != nil {
1121+
return fmt.Errorf("failed to load key: %w", err)
1122+
}
1123+
1124+
ui.Warn("PRIVATE KEY - Store securely! Anyone with this key can decrypt your auth tokens.")
1125+
fmt.Println()
1126+
fmt.Println(privateKey)
1127+
fmt.Println()
1128+
ui.Info("Copy this key to your password manager or secure storage.")
1129+
ui.Info("Use 'opencode-sync key import <key>' on other machines.")
1130+
1131+
return nil
1132+
}
1133+
1134+
func runKeyImport(key string) error {
1135+
if _, err := crypto.NewAgeEncryption(key); err != nil {
1136+
return fmt.Errorf("invalid key format: %w", err)
1137+
}
1138+
1139+
p, err := paths.Get()
1140+
if err != nil {
1141+
return fmt.Errorf("failed to get paths: %w", err)
1142+
}
1143+
1144+
if err := p.EnsureDirs(); err != nil {
1145+
return fmt.Errorf("failed to create directories: %w", err)
1146+
}
1147+
1148+
keyFile := p.KeyFile()
1149+
if _, err := os.Stat(keyFile); err == nil {
1150+
confirmed, err := ui.Confirm("Key already exists. Overwrite?", "This will replace your existing encryption key")
1151+
if err != nil {
1152+
return err
1153+
}
1154+
if !confirmed {
1155+
ui.Info("Import cancelled")
1156+
return nil
1157+
}
1158+
}
1159+
1160+
if err := crypto.SaveKeyToFile(key, keyFile); err != nil {
1161+
return fmt.Errorf("failed to save key: %w", err)
1162+
}
1163+
1164+
cfg, err := config.Load()
1165+
if err == nil && cfg != nil {
1166+
cfg.Encryption.Enabled = true
1167+
if err := config.Save(cfg); err != nil {
1168+
ui.Warn("Key saved but failed to update config. Run: opencode-sync config set encryption.enabled true")
1169+
}
1170+
}
1171+
1172+
ui.Success(fmt.Sprintf("Key imported to: %s", keyFile))
1173+
ui.Info("You can now pull encrypted data from your repo.")
1174+
1175+
return nil
1176+
}
1177+
1178+
func runKeyRegen() error {
1179+
ui.Warn("WARNING: Regenerating your key will make previously encrypted data unrecoverable!")
1180+
ui.Warn("Only proceed if you've lost your key and need to start fresh.")
1181+
fmt.Println()
1182+
1183+
confirmed, err := ui.Confirm("Regenerate encryption key?", "Previously encrypted auth tokens will be lost")
1184+
if err != nil {
1185+
return err
1186+
}
1187+
if !confirmed {
1188+
ui.Info("Cancelled")
1189+
return nil
1190+
}
1191+
1192+
p, err := paths.Get()
1193+
if err != nil {
1194+
return fmt.Errorf("failed to get paths: %w", err)
1195+
}
1196+
1197+
if err := p.EnsureDirs(); err != nil {
1198+
return fmt.Errorf("failed to create directories: %w", err)
1199+
}
1200+
1201+
keyPair, err := crypto.GenerateKey()
1202+
if err != nil {
1203+
return fmt.Errorf("failed to generate key: %w", err)
1204+
}
1205+
1206+
keyFile := p.KeyFile()
1207+
if err := crypto.SaveKeyToFile(keyPair.PrivateKey, keyFile); err != nil {
1208+
return fmt.Errorf("failed to save key: %w", err)
1209+
}
1210+
1211+
cfg, err := config.Load()
1212+
if err == nil && cfg != nil {
1213+
cfg.Encryption.Enabled = true
1214+
if err := config.Save(cfg); err != nil {
1215+
ui.Warn("Key saved but failed to update config")
1216+
}
1217+
}
1218+
1219+
ui.Success(fmt.Sprintf("New encryption key saved to: %s", keyFile))
1220+
fmt.Println()
1221+
ui.Warn("IMPORTANT: Back up your new key!")
1222+
ui.Info("Run 'opencode-sync key export' to view it for backup.")
1223+
1224+
return nil
1225+
}
1226+
1227+
func runRebind(newURL string) error {
1228+
cfg, err := config.Load()
1229+
if err != nil {
1230+
return fmt.Errorf("failed to load config: %w", err)
1231+
}
1232+
if cfg == nil {
1233+
return fmt.Errorf("no configuration found. Run 'opencode-sync setup' first")
1234+
}
1235+
1236+
p, err := paths.Get()
1237+
if err != nil {
1238+
return fmt.Errorf("failed to get paths: %w", err)
1239+
}
1240+
1241+
repoDir := p.SyncRepoDir()
1242+
gitDir := filepath.Join(repoDir, ".git")
1243+
1244+
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
1245+
return fmt.Errorf("no repository found. Run 'opencode-sync init' or 'opencode-sync clone' first")
1246+
}
1247+
1248+
oldURL := cfg.Repo.URL
1249+
if oldURL == newURL {
1250+
ui.Info("URL is already set to: " + newURL)
1251+
return nil
1252+
}
1253+
1254+
ui.Info(fmt.Sprintf("Changing remote URL from: %s", oldURL))
1255+
ui.Info(fmt.Sprintf(" to: %s", newURL))
1256+
1257+
if err := runGitCommand(repoDir, "remote", "set-url", "origin", newURL); err != nil {
1258+
return fmt.Errorf("failed to update git remote: %w", err)
1259+
}
1260+
1261+
cfg.Repo.URL = newURL
1262+
if err := config.Save(cfg); err != nil {
1263+
return fmt.Errorf("failed to save config: %w", err)
1264+
}
1265+
1266+
ui.Success("Repository URL updated!")
1267+
ui.Info("Run 'opencode-sync sync' to sync with the new remote.")
1268+
1269+
return nil
1270+
}
1271+
1272+
func runGitCommand(dir string, args ...string) error {
1273+
cmd := exec.Command("git", args...)
1274+
if dir != "" {
1275+
cmd.Dir = dir
1276+
}
1277+
cmd.Stdout = os.Stdout
1278+
cmd.Stderr = os.Stderr
1279+
return cmd.Run()
1280+
}

0 commit comments

Comments
 (0)