* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen - pkg/credential: new package with AES-256-GCM enc:// credential format, HKDF-SHA256 key derivation (passphrase + optional SSH key binding), ErrPassphraseRequired / ErrDecryptionFailed sentinel errors, and PassphraseProvider hook for runtime passphrase injection - pkg/credential/store: lock-free SecureStore via atomic.Pointer[string]; passphrase never written to disk or os.Environ - pkg/credential/keygen: ed25519 SSH key generation helper used by onboard - pkg/config: replace os.Getenv(PassphraseEnvVar) with credential.PassphraseProvider() at all three call sites so that LoadConfig and SaveConfig use whatever passphrase source is active - cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate picoclaw-specific SSH key, re-encrypt existing config on re-onboard - docs/credential_encryption.md: design doc for the enc:// format * fix(credential): address Copilot review comments on PR #1521 - credential.go: decouple ErrPassphraseRequired from env var name; message is now 'enc:// passphrase required' since PassphraseProvider may come from any source, not just os.Environ - credential.go: Resolver resolves symlinks via EvalSymlinks before the isWithinDir containment check, preventing symlink-based path traversal for file:// credential references - store.go: tighten comment to describe only what SecureStore guarantees (in-memory only); remove claims about how callers transport the value - store_test.go: replace the meaningless GetReturnsCopy test (Go strings are immutable, equality across two calls proves nothing) with TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under 10-goroutine concurrent Set/Get load - config_test.go: update error-message assertion to match new sentinel text - docs/credential_encryption.md: remove reference to non-existent 'picoclaw encrypt' subcommand; describe the onboard flow instead * fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte * fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode * lint: fix credential keygen lint, fix test keygen * onboard: make encryption opt-in via --enc flag Encryption (passphrase prompt + SSH key generation) is now only triggered when the user passes --enc to 'picoclaw onboard'. Without the flag, onboard skips the credential-encryption setup and writes a plain config + workspace templates directly. - Add --enc BoolFlag in NewOnboardCommand() - Pass encrypt bool into onboard() - Guard passphrase prompt, SSH key generation, and related env-var setup behind the encrypt branch - Adjust 'Next steps' output so the passphrase reminder only appears when --enc was used
210 lines
6.2 KiB
Go
210 lines
6.2 KiB
Go
package onboard
|
||
|
||
import (
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"golang.org/x/term"
|
||
|
||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||
"github.com/sipeed/picoclaw/pkg/config"
|
||
"github.com/sipeed/picoclaw/pkg/credential"
|
||
)
|
||
|
||
func onboard(encrypt bool) {
|
||
configPath := internal.GetConfigPath()
|
||
|
||
configExists := false
|
||
if _, err := os.Stat(configPath); err == nil {
|
||
configExists = true
|
||
if encrypt {
|
||
// Only ask for confirmation when *both* config and SSH key already exist,
|
||
// indicating a full re-onboard that would reset the config to defaults.
|
||
sshKeyPath, _ := credential.DefaultSSHKeyPath()
|
||
if _, err := os.Stat(sshKeyPath); err == nil {
|
||
// Both exist — confirm a full reset.
|
||
fmt.Printf("Config already exists at %s\n", configPath)
|
||
fmt.Print("Overwrite config with defaults? (y/n): ")
|
||
var response string
|
||
fmt.Scanln(&response)
|
||
if response != "y" {
|
||
fmt.Println("Aborted.")
|
||
return
|
||
}
|
||
configExists = false // user agreed to reset; treat as fresh
|
||
}
|
||
// Config exists but SSH key is missing — keep existing config, only add SSH key.
|
||
}
|
||
}
|
||
|
||
var err error
|
||
if encrypt {
|
||
fmt.Println("\nSet up credential encryption")
|
||
fmt.Println("-----------------------------")
|
||
passphrase, pErr := promptPassphrase()
|
||
if pErr != nil {
|
||
fmt.Printf("Error: %v\n", pErr)
|
||
os.Exit(1)
|
||
}
|
||
// Expose the passphrase to credential.PassphraseProvider (which calls
|
||
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
|
||
// This process is a one-shot CLI tool; the env var is never exposed outside
|
||
// the current process and disappears when it exits.
|
||
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
||
|
||
if err = setupSSHKey(); err != nil {
|
||
fmt.Printf("Error generating SSH key: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
var cfg *config.Config
|
||
if configExists {
|
||
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
|
||
cfg, err = config.LoadConfig(configPath)
|
||
if err != nil {
|
||
fmt.Printf("Error loading existing config: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
} else {
|
||
cfg = config.DefaultConfig()
|
||
}
|
||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||
fmt.Printf("Error saving config: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
workspace := cfg.WorkspacePath()
|
||
createWorkspaceTemplates(workspace)
|
||
|
||
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||
fmt.Println("\nNext steps:")
|
||
if encrypt {
|
||
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||
fmt.Println("")
|
||
fmt.Println(" 2. Add your API key to", configPath)
|
||
} else {
|
||
fmt.Println(" 1. Add your API key to", configPath)
|
||
}
|
||
fmt.Println("")
|
||
fmt.Println(" Recommended:")
|
||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||
fmt.Println(" - Ollama: https://ollama.com (local, free)")
|
||
fmt.Println("")
|
||
fmt.Println(" See README.md for 17+ supported providers.")
|
||
fmt.Println("")
|
||
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||
}
|
||
|
||
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||
// (with echo disabled) and returns it. Returns an error if the passphrase is
|
||
// empty or if the two inputs do not match.
|
||
func promptPassphrase() (string, error) {
|
||
fmt.Print("Enter passphrase for credential encryption: ")
|
||
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||
fmt.Println()
|
||
if err != nil {
|
||
return "", fmt.Errorf("reading passphrase: %w", err)
|
||
}
|
||
if len(p1) == 0 {
|
||
return "", fmt.Errorf("passphrase must not be empty")
|
||
}
|
||
|
||
fmt.Print("Confirm passphrase: ")
|
||
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||
fmt.Println()
|
||
if err != nil {
|
||
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
|
||
}
|
||
|
||
if string(p1) != string(p2) {
|
||
return "", fmt.Errorf("passphrases do not match")
|
||
}
|
||
return string(p1), nil
|
||
}
|
||
|
||
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
||
// If the key already exists the user is warned and asked to confirm overwrite.
|
||
// Answering anything other than "y" keeps the existing key (not an error).
|
||
func setupSSHKey() error {
|
||
keyPath, err := credential.DefaultSSHKeyPath()
|
||
if err != nil {
|
||
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
||
}
|
||
|
||
if _, err := os.Stat(keyPath); err == nil {
|
||
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
||
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
||
fmt.Print(" Overwrite? (y/n): ")
|
||
var response string
|
||
fmt.Scanln(&response)
|
||
if response != "y" {
|
||
fmt.Println("Keeping existing SSH key.")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||
return err
|
||
}
|
||
fmt.Printf("SSH key generated: %s\n", keyPath)
|
||
return nil
|
||
}
|
||
|
||
func createWorkspaceTemplates(workspace string) {
|
||
err := copyEmbeddedToTarget(workspace)
|
||
if err != nil {
|
||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||
}
|
||
}
|
||
|
||
func copyEmbeddedToTarget(targetDir string) error {
|
||
// Ensure target directory exists
|
||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||
return fmt.Errorf("Failed to create target directory: %w", err)
|
||
}
|
||
|
||
// Walk through all files in embed.FS
|
||
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Skip directories
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
|
||
// Read embedded file
|
||
data, err := embeddedFiles.ReadFile(path)
|
||
if err != nil {
|
||
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
|
||
}
|
||
|
||
new_path, err := filepath.Rel("workspace", path)
|
||
if err != nil {
|
||
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
|
||
}
|
||
|
||
// Build target file path
|
||
targetPath := filepath.Join(targetDir, new_path)
|
||
|
||
// Ensure target file's directory exists
|
||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
|
||
}
|
||
|
||
// Write file
|
||
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
||
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
return err
|
||
}
|