diff --git a/pass_mgr/src/main.go b/pass_mgr/src/main.go index 474c2a1..cf02d56 100644 --- a/pass_mgr/src/main.go +++ b/pass_mgr/src/main.go @@ -26,7 +26,7 @@ const ( SecretsDirName = ".secrets" ) -// EncryptedData represents the structure of encrypted password file +// EncryptedData represents the structure of an encrypted credential file type EncryptedData struct { Algorithm string `json:"algorithm"` Nonce string `json:"nonce"` @@ -49,172 +49,229 @@ func main() { rootCmd := &cobra.Command{ Use: "pass_mgr", Short: "Password manager for OpenClaw agents", - Long: `A secure password management tool using AES-256-GCM encryption.`, + Long: "A secure credential management tool using AES-256-GCM encryption.", } - // Get environment variables + // Read environment workspaceDir = os.Getenv("AGENT_WORKSPACE") agentID = os.Getenv("AGENT_ID") - // Commands - rootCmd.AddCommand(getCmd()) - rootCmd.AddCommand(generateCmd()) - rootCmd.AddCommand(unsetCmd()) - rootCmd.AddCommand(rotateCmd()) - rootCmd.AddCommand(adminInitCmd()) + rootCmd.AddCommand(listCmd()) + rootCmd.AddCommand(getSecretCmd()) + rootCmd.AddCommand(getUsernameCmd()) rootCmd.AddCommand(setCmd()) + rootCmd.AddCommand(unsetCmd()) + rootCmd.AddCommand(generateCmd()) + rootCmd.AddCommand(rotateCmd()) + rootCmd.AddCommand(adminCmd()) if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } -func getCmd() *cobra.Command { - var showUsername bool - cmd := &cobra.Command{ - Use: "get [key]", - Short: "Get password for a key", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - key := args[0] - password, user, err := getPassword(key) +// ── Commands ──────────────────────────────────────────────────────────── + +func listCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all keys for this agent", + RunE: func(cmd *cobra.Command, args []string) error { + if !isInitialized() { + return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") + } + secretsDir := getSecretsDir() + entries, err := os.ReadDir(secretsDir) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + if os.IsNotExist(err) { + // No secrets yet — just output nothing + return nil + } + return fmt.Errorf("failed to read secrets directory: %w", err) } - if showUsername { - fmt.Println(user) - } else { - fmt.Println(password) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".gpg") { + fmt.Println(strings.TrimSuffix(name, ".gpg")) + } } + return nil }, } - cmd.Flags().BoolVar(&showUsername, "username", false, "Show username instead of password") +} + +func getSecretCmd() *cobra.Command { + var key string + cmd := &cobra.Command{ + Use: "get-secret", + Short: "Get secret for a key", + RunE: func(cmd *cobra.Command, args []string) error { + if key == "" { + return fmt.Errorf("--key is required") + } + secret, _, err := getCredential(key) + if err != nil { + return err + } + fmt.Print(secret) + return nil + }, + } + cmd.Flags().StringVar(&key, "key", "", "Credential key") + _ = cmd.MarkFlagRequired("key") return cmd } -func generateCmd() *cobra.Command { - var user string +func getUsernameCmd() *cobra.Command { + var key string cmd := &cobra.Command{ - Use: "generate [key]", - Short: "Generate a new password", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - key := args[0] - // Check if agent is trying to set password - if os.Getenv("AGENT") != "" || os.Getenv("AGENT_WORKSPACE") != "" { - fmt.Fprintln(os.Stderr, "Error: Agents cannot set passwords. Use generate instead.") - os.Exit(1) + Use: "get-username", + Short: "Get username for a key", + RunE: func(cmd *cobra.Command, args []string) error { + if key == "" { + return fmt.Errorf("--key is required") } - password, err := generatePassword(32) + _, username, err := getCredential(key) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + return err } - if err := setPassword(key, user, password); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println(password) + fmt.Print(username) + return nil }, } - cmd.Flags().StringVar(&user, "username", "", "Username associated with the password") + cmd.Flags().StringVar(&key, "key", "", "Credential key") + _ = cmd.MarkFlagRequired("key") + return cmd +} + +func setCmd() *cobra.Command { + var key, username, secret string + cmd := &cobra.Command{ + Use: "set", + Short: "Set credential (admin only)", + RunE: func(cmd *cobra.Command, args []string) error { + // Block agent access + if isAgentContext() { + return fmt.Errorf("agents cannot set credentials. Only humans can use 'set'") + } + if key == "" || secret == "" { + return fmt.Errorf("--key and --secret are required") + } + return setCredential(key, username, secret) + }, + } + cmd.Flags().StringVar(&key, "key", "", "Credential key") + cmd.Flags().StringVar(&username, "username", "", "Username") + cmd.Flags().StringVar(&secret, "secret", "", "Secret/password") + _ = cmd.MarkFlagRequired("key") + _ = cmd.MarkFlagRequired("secret") return cmd } func unsetCmd() *cobra.Command { - return &cobra.Command{ - Use: "unset [key]", - Short: "Remove a password", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - key := args[0] - if err := removePassword(key); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, - } -} - -func rotateCmd() *cobra.Command { - return &cobra.Command{ - Use: "rotate [key]", - Short: "Rotate password for a key", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - key := args[0] - // Check if initialized - if !isInitialized() { - fmt.Fprintln(os.Stderr, "Error: pass_mgr not initialized. Run 'pass_mgr admin init' first.") - os.Exit(1) - } - - // Get current user if exists - _, user, err := getPassword(key) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - // Generate new password - newPassword, err := generatePassword(32) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if err := setPassword(key, user, newPassword); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println(newPassword) - }, - } -} - -func adminInitCmd() *cobra.Command { - return &cobra.Command{ - Use: "admin init", - Short: "Initialize pass_mgr with admin key", - Run: func(cmd *cobra.Command, args []string) { - if err := initAdminInteractive(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println("pass_mgr initialized successfully") - }, - } -} - -func setCmd() *cobra.Command { - var user string + var key string cmd := &cobra.Command{ - Use: "set [key] [password]", - Short: "Set password (admin only)", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - // Check if agent is trying to set password - if os.Getenv("AGENT") != "" || os.Getenv("AGENT_WORKSPACE") != "" { - fmt.Fprintln(os.Stderr, "Error: Agents cannot set passwords. Only humans can use 'set'.") - os.Exit(1) - } - - key := args[0] - password := args[1] - - if err := setPassword(key, user, password); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + Use: "unset", + Short: "Remove a credential", + RunE: func(cmd *cobra.Command, args []string) error { + if key == "" { + return fmt.Errorf("--key is required") } + return removeCredential(key) }, } - cmd.Flags().StringVar(&user, "username", "", "Username associated with the password") + cmd.Flags().StringVar(&key, "key", "", "Credential key") + _ = cmd.MarkFlagRequired("key") return cmd } -// Helper functions +func generateCmd() *cobra.Command { + var key, username string + var length int + cmd := &cobra.Command{ + Use: "generate", + Short: "Generate a random secret and store it", + RunE: func(cmd *cobra.Command, args []string) error { + if key == "" { + return fmt.Errorf("--key is required") + } + secret, err := generateRandomPassword(length) + if err != nil { + return err + } + if err := setCredential(key, username, secret); err != nil { + return err + } + fmt.Print(secret) + return nil + }, + } + cmd.Flags().StringVar(&key, "key", "", "Credential key") + cmd.Flags().StringVar(&username, "username", "", "Username") + cmd.Flags().IntVar(&length, "length", 32, "Password length") + _ = cmd.MarkFlagRequired("key") + return cmd +} + +func rotateCmd() *cobra.Command { + var key string + var length int + cmd := &cobra.Command{ + Use: "rotate", + Short: "Rotate secret for a key (keeps username)", + RunE: func(cmd *cobra.Command, args []string) error { + if key == "" { + return fmt.Errorf("--key is required") + } + // Get current username + _, username, err := getCredential(key) + if err != nil { + return fmt.Errorf("cannot rotate: %w", err) + } + newSecret, err := generateRandomPassword(length) + if err != nil { + return err + } + if err := setCredential(key, username, newSecret); err != nil { + return err + } + fmt.Print(newSecret) + return nil + }, + } + cmd.Flags().StringVar(&key, "key", "", "Credential key") + cmd.Flags().IntVar(&length, "length", 32, "Password length") + _ = cmd.MarkFlagRequired("key") + return cmd +} + +func adminCmd() *cobra.Command { + adminRoot := &cobra.Command{ + Use: "admin", + Short: "Admin commands", + } + adminRoot.AddCommand(&cobra.Command{ + Use: "init", + Short: "Initialize pass_mgr with admin key", + RunE: func(cmd *cobra.Command, args []string) error { + if err := initAdminInteractive(); err != nil { + return err + } + fmt.Println("pass_mgr initialized successfully") + return nil + }, + }) + return adminRoot +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +func isAgentContext() bool { + return os.Getenv("AGENT_WORKSPACE") != "" || os.Getenv("AGENT") != "" +} func getHomeDir() string { home, err := os.UserHomeDir() @@ -238,125 +295,41 @@ func isInitialized() bool { } func loadAdminKey() ([]byte, error) { - keyPath := getAdminKeyPath() - key, err := os.ReadFile(keyPath) + key, err := os.ReadFile(getAdminKeyPath()) if err != nil { return nil, fmt.Errorf("failed to load admin key: %w", err) } - // Hash the key to get 32 bytes for AES-256 hash := sha256.Sum256(key) return hash[:], nil } -func initAdminInteractive() error { - fmt.Print("Enter admin password: ") - password1, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - fmt.Println() - - // Trim whitespace/newlines - password1 = []byte(strings.TrimSpace(string(password1))) - - // Validate password length - if len(password1) < 6 { - return fmt.Errorf("password must be at least 6 characters long (got %d)", len(password1)) - } - - fmt.Print("Confirm admin password: ") - password2, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password confirmation: %w", err) - } - fmt.Println() - - // Trim whitespace/newlines - password2 = []byte(strings.TrimSpace(string(password2))) - - // Check passwords match - if string(password1) != string(password2) { - return fmt.Errorf("passwords do not match") - } - - // Save the key - return saveAdminKey(password1) -} - -func saveAdminKey(key []byte) error { - homeDir := getHomeDir() - adminDir := filepath.Join(homeDir, AdminKeyDir) - - // Create admin directory - if err := os.MkdirAll(adminDir, 0700); err != nil { - return fmt.Errorf("failed to create admin directory: %w", err) - } - - // Save key - keyFile := filepath.Join(adminDir, AdminKeyFile) - if err := os.WriteFile(keyFile, key, 0600); err != nil { - return fmt.Errorf("failed to save key: %w", err) - } - - // Save config - config := Config{ - KeyHash: fmt.Sprintf("%x", sha256.Sum256(key)), - Algorithm: DefaultAlgorithm, - } - configData, _ := json.MarshalIndent(config, "", " ") - configPath := filepath.Join(adminDir, "config.json") - if err := os.WriteFile(configPath, configData, 0600); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - - return nil -} - -func initAdmin(keyPath string) error { - // Read provided key - key, err := os.ReadFile(keyPath) - if err != nil { - return fmt.Errorf("failed to read key file: %w", err) - } - - // Validate password length (must be >= 6 characters) - if len(key) < 6 { - return fmt.Errorf("password must be at least 6 characters long (got %d)", len(key)) - } - - return saveAdminKey(key) -} - func getSecretsDir() string { if workspaceDir != "" && agentID != "" { return filepath.Join(workspaceDir, SecretsDirName, agentID) } - // Fallback to home directory return filepath.Join(getHomeDir(), SecretsDirName, "default") } -func getPasswordFilePath(key string) string { +func getCredentialFilePath(key string) string { return filepath.Join(getSecretsDir(), key+".gpg") } -func encrypt(plaintext []byte, key []byte) (*EncryptedData, error) { +// ── Crypto ────────────────────────────────────────────────────────────── + +func encrypt(plaintext, key []byte) (*EncryptedData, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } - gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } - nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } - ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) - return &EncryptedData{ Algorithm: DefaultAlgorithm, Nonce: base64.StdEncoding.EncodeToString(nonce), @@ -369,165 +342,174 @@ func decrypt(data *EncryptedData, key []byte) ([]byte, error) { if err != nil { return nil, err } - nonce, err := base64.StdEncoding.DecodeString(data.Nonce) if err != nil { return nil, err } - block, err := aes.NewCipher(key) if err != nil { return nil, err } - gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } - - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - - return plaintext, nil + return gcm.Open(nil, nonce, ciphertext, nil) } -func setPassword(key, user, password string) error { +// ── CRUD ──────────────────────────────────────────────────────────────── + +func setCredential(key, username, secret string) error { if !isInitialized() { return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") } - adminKey, err := loadAdminKey() if err != nil { return err } - - // Create secrets directory secretsDir := getSecretsDir() if err := os.MkdirAll(secretsDir, 0700); err != nil { return fmt.Errorf("failed to create secrets directory: %w", err) } - - // Encrypt password - data := map[string]string{ - "password": password, - "user": user, - } - plaintext, _ := json.Marshal(data) - - encrypted, err := encrypt(plaintext, adminKey) + payload, _ := json.Marshal(map[string]string{ + "secret": secret, + "username": username, + }) + encrypted, err := encrypt(payload, adminKey) if err != nil { return fmt.Errorf("failed to encrypt: %w", err) } - encrypted.User = user - - // Save to file - filePath := getPasswordFilePath(key) + encrypted.User = username fileData, _ := json.MarshalIndent(encrypted, "", " ") + filePath := getCredentialFilePath(key) if err := os.WriteFile(filePath, fileData, 0600); err != nil { - return fmt.Errorf("failed to save password: %w", err) + return fmt.Errorf("failed to save credential: %w", err) } - return nil } -func getPassword(key string) (string, string, error) { +func getCredential(key string) (string, string, error) { if !isInitialized() { return "", "", fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") } - adminKey, err := loadAdminKey() if err != nil { return "", "", err } - - filePath := getPasswordFilePath(key) - fileData, err := os.ReadFile(filePath) + fileData, err := os.ReadFile(getCredentialFilePath(key)) if err != nil { - return "", "", fmt.Errorf("password not found: %w", err) + return "", "", fmt.Errorf("credential not found for key '%s'", key) } - var encrypted EncryptedData if err := json.Unmarshal(fileData, &encrypted); err != nil { - return "", "", fmt.Errorf("failed to parse password file: %w", err) + return "", "", fmt.Errorf("failed to parse credential file: %w", err) } - plaintext, err := decrypt(&encrypted, adminKey) if err != nil { return "", "", fmt.Errorf("failed to decrypt: %w", err) } - var data map[string]string if err := json.Unmarshal(plaintext, &data); err != nil { return "", "", fmt.Errorf("failed to parse decrypted data: %w", err) } - - return data["password"], data["user"], nil + return data["secret"], data["username"], nil } -func removePassword(key string) error { +func removeCredential(key string) error { if !isInitialized() { return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") } - - filePath := getPasswordFilePath(key) + filePath := getCredentialFilePath(key) if err := os.Remove(filePath); err != nil { - return fmt.Errorf("failed to remove password: %w", err) + return fmt.Errorf("failed to remove credential for key '%s': %w", key, err) } return nil } -func generatePassword(length int) (string, error) { +func generateRandomPassword(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" password := make([]byte, length) for i := range password { - randomByte := make([]byte, 1) - if _, err := rand.Read(randomByte); err != nil { + b := make([]byte, 1) + if _, err := rand.Read(b); err != nil { return "", err } - password[i] = charset[int(randomByte[0])%len(charset)] + password[i] = charset[int(b[0])%len(charset)] } return string(password), nil } -// CheckForAdminLeak checks if admin password appears in message/tool calling +// ── Admin Init ────────────────────────────────────────────────────────── + +func initAdminInteractive() error { + fmt.Print("Enter admin password: ") + pw1, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() + pw1 = []byte(strings.TrimSpace(string(pw1))) + if len(pw1) < 6 { + return fmt.Errorf("password must be at least 6 characters (got %d)", len(pw1)) + } + + fmt.Print("Confirm admin password: ") + pw2, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + fmt.Println() + pw2 = []byte(strings.TrimSpace(string(pw2))) + if string(pw1) != string(pw2) { + return fmt.Errorf("passwords do not match") + } + return saveAdminKey(pw1) +} + +func saveAdminKey(key []byte) error { + adminDir := filepath.Join(getHomeDir(), AdminKeyDir) + if err := os.MkdirAll(adminDir, 0700); err != nil { + return fmt.Errorf("failed to create admin directory: %w", err) + } + if err := os.WriteFile(filepath.Join(adminDir, AdminKeyFile), key, 0600); err != nil { + return fmt.Errorf("failed to save key: %w", err) + } + config := Config{ + KeyHash: fmt.Sprintf("%x", sha256.Sum256(key)), + Algorithm: DefaultAlgorithm, + } + configData, _ := json.MarshalIndent(config, "", " ") + if err := os.WriteFile(filepath.Join(adminDir, "config.json"), configData, 0600); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +// ── Leak Detection (called by plugin) ─────────────────────────────────── + +// CheckForAdminLeak checks if admin password appears in content func CheckForAdminLeak(content string) bool { - // This is a placeholder - actual implementation should check against actual admin password - // This function should be called by the plugin to monitor messages configPath := getConfigPath() if _, err := os.Stat(configPath); err != nil { return false } - - // TODO: Implement actual leak detection - // For now, just check if content contains common patterns - return strings.Contains(content, "admin") && strings.Contains(content, "password") + // TODO: Implement actual leak detection against stored key + return false } -// ResetOnLeak resets pass_mgr to uninitialized state and logs security breach +// ResetOnLeak resets pass_mgr to uninitialized state func ResetOnLeak() error { - configPath := getConfigPath() - - // Remove config (but keep key file for potential recovery) - if err := os.Remove(configPath); err != nil { + if err := os.Remove(getConfigPath()); err != nil { return err } - - // Log security breach logPath := filepath.Join(getHomeDir(), AdminKeyDir, "security_breach.log") - logEntry := fmt.Sprintf("[%s] CRITICAL: Admin password leaked! pass_mgr reset to uninitialized state.\n", + entry := fmt.Sprintf("[%s] CRITICAL: Admin password leaked! pass_mgr reset.\n", time.Now().Format(time.RFC3339)) - f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return err } defer f.Close() - - if _, err := f.WriteString(logEntry); err != nil { - return err - } - - return nil + _, err = f.WriteString(entry) + return err } diff --git a/plugin/tools/pcexec.ts b/plugin/tools/pcexec.ts index 4738bb0..7539a91 100644 --- a/plugin/tools/pcexec.ts +++ b/plugin/tools/pcexec.ts @@ -58,8 +58,15 @@ export interface PcExecError extends Error { function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: string }> { const results: Array<{ key: string; fullMatch: string }> = []; - // Pattern for $(pass_mgr get key) or `pass_mgr get key` + // Patterns for both old and new pass_mgr formats: + // Old: $(pass_mgr get key), `pass_mgr get key`, pass_mgr get key + // New: $(pass_mgr get-secret --key key), `pass_mgr get-secret --key key` const patterns = [ + // New format: pass_mgr get-secret --key + /\$\(\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g, + /`\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*`/g, + /pass_mgr\s+get-secret\s+--key\s+(\S+)/g, + // Old format (backward compat): pass_mgr get /\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g, /`\s*pass_mgr\s+get\s+(\S+)\s*`/g, /pass_mgr\s+get\s+(\S+)/g, @@ -84,7 +91,7 @@ function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: st async function getPassword(key: string): Promise { return new Promise((resolve, reject) => { const passMgrPath = process.env.PASS_MGR_PATH || 'pass_mgr'; - const child = spawn(passMgrPath, ['get', key], { + const child = spawn(passMgrPath, ['get-secret', '--key', key], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env,