diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..df4aad9 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,57 @@ +name: Quality & Security + +on: + push: + branches: [ "master", "main" ] + pull_request: + branches: [ "master", "main" ] + +jobs: + quality: + name: Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v4 + with: + go-version: '1.25' + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev + + - name: Format Check + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Go code is not formatted:" + gofmt -d . + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Staticcheck + uses: dominikh/staticcheck-action@v1.3.0 + with: + version: "latest" + install-go: false + + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v4 + with: + go-version: '1.25' + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run Vulnerability Check + run: govulncheck ./... + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: ./... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bf66764 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test and Build + +on: + push: + branches: [ "master", "main" ] + pull_request: + branches: [ "master", "main" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.25' + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsqlite3-dev + + - name: Build + run: go build -v ./cmd/rapg/... + + - name: Test + run: go test -v -race ./... diff --git a/.gitignore b/.gitignore index e69de29..8ab48e0 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,43 @@ +# Binaries +/rapg +*.exe +*.dll +*.so +*.dylib +*.test + +# Output & Build +/dist/ +coverage.out + +# OS Specific +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Editors / IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# Go +go.work +go.work.sum +vendor/ + +# Project Specific +# Local database (SQLite) +*.db +*.db-journal +*.db-wal + +# Generated env files +.env + +# User config directory (if created locally during dev) +.rapg/ \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..fd4a659 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,104 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=1 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + main: ./cmd/rapg/main.go + ldflags: + - -s -w + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of uname. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + +brews: + - + name: rapg + + # GitHub/GitLab repository to push the formula to + repository: + owner: kanywst + name: homebrew-tap + token: "{{ .Env.TAP_GITHUB_TOKEN }}" + + # Template for the url which is determined by the given Token (github, gitlab or gitea) + url_template: "https://github.com/kanywst/rapg/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + + # Git author used to commit to the repository. + # Defaults are shown. + commit_author: + name: goreleaserbot + email: goreleaser@carlosbecker.com + + # The project name and current git tag are used in the format string. + commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" + + # Folder inside the repository to put the formula. + # Default is the root folder. + directory: Formula + + # Your app's homepage. + # Default is empty. + homepage: "https://github.com/kanywst/rapg" + + # Template of your app's description. + # Default is empty. + description: "The Developer-First Secret Manager." + + # SPDX identifier of your app's license. + # Default is empty. + license: "MIT" + + # Setting this will prevent goreleaser to actually try to commit the updated + # formula - instead, the formula file will be stored on the dist folder only, + # leaving the responsibility of publishing it to the user. + # If set to auto, the release will not be published to the homebrew tap if + # one of and Linux and macOS (darwin) builds are missing. + skip_upload: auto # Upload requires a real TAP repo, skipping for this demo context. + + # So you can `brew test` your formula. + # Default is empty. + test: | + system "#{bin}/rapg gen --help" + + # Custom install script for brew. + # Default is 'bin.install "program"'. + install: | + bin.install "rapg" diff --git a/Makefile b/Makefile index 86a9a17..dc2d2c0 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,39 @@ -.DEFAULT_GOAL := help +.PHONY: build run test clean fmt vet install demo -ifeq ($(GOPATH),) - GOPATH := $(shell pwd) -endif +BINARY_NAME=rapg +MAIN_PATH=cmd/rapg/main.go -export GOPATH +# Build the binary +build: + go build -o $(BINARY_NAME) $(MAIN_PATH) -BIN_NAME := ra +# Run properly (interactive) +run: + go run $(MAIN_PATH) -.PHONY: help -help: - @echo "Usage: make [target]" - @echo "" - @echo "Targets:" - @echo " build-mac Build for macOS" - @echo " build-linux Build for Linux" - @echo " clean Clean build artifacts" - @echo " help Show this help message" +# Run tests +test: + go test -v ./... -.PHONY: build-mac -build-mac: - @echo "Building for macOS..." - GOOS=darwin GOARCH=amd64 go build -o ${GOPATH}/$(BIN_NAME) cmd/rapg/main.go +# Format code +fmt: + go fmt ./... -.PHONY: build-linux -build-linux: - @echo "Building for Linux..." - GOOS=linux GOARCH=amd64 go build -o $(GOPATH)/$(BIN_NAME).linux cmd/rapg/main.go +# Static analysis +vet: + go vet ./... -.PHONY: clean +# Install to GOPATH/bin +install: + go install $(MAIN_PATH) + +# Clean build artifacts clean: - @echo "Cleaning build artifacts..." - rm -rf $(GOPATH) + rm -f $(BINARY_NAME) + rm -rf dist/ + rm -f coverage.out + rm -f demo.gif + +# Update the demo GIF (requires vhs) +demo: + vhs demo.tape diff --git a/README.md b/README.md index 1072773..396e1d9 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,122 @@ -# rapg - -Rapg is a password manager that allows you to generate and manage strong passwords. -It stands for Random Password Generator. -We think of it as being inspired by gopass. - -## Resources - - -- [rapg](#rapg) - - [Resources](#resources) - - [Installation](#installation) - - [from Github](#from-github) - - [Usage](#usage) - - [Basic Usage](#basic-usage) - - [Flags](#flags) - - [Generate random passwords of specified length](#generate-random-passwords-of-specified-length) - - [Create a key to encrypt and store the password](#create-a-key-to-encrypt-and-store-the-password) - - [Add password with a specific domain and username set](#add-password-with-a-specific-domain-and-username-set) - - [Remove password with a specific domain and username set](#remove-password-with-a-specific-domain-and-username-set) - - [Show the list of passwords](#show-the-list-of-passwords) - - [Displays the stored password](#displays-the-stored-password) - - [License](#license) - - +
+ +# Rapg + +### The Developer-First Secret Manager + +[![Go Version](https://img.shields.io/github/go-mod/go-version/kanywst/rapg?style=flat-square)](https://go.dev/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/kanywst/rapg/test.yml?branch=main&style=flat-square)](https://github.com/kanywst/rapg/actions) +[![License](https://img.shields.io/github/license/kanywst/rapg?style=flat-square)](LICENSE) + +**Stop sharing `.env` files over Slack.**
+**Stop keeping cleartext credentials on your disk.** + +![Demo](demo.gif) + +
+ +--- + +## What is Rapg? + +**Rapg** (Rapid/pg) is a secure, TUI-based secret manager designed specifically for developers who live in the terminal. It allows you to store credentials securely and inject them directly into your development processes without ever writing `.env` files to disk. ## Installation -### from Github +Required: Go 1.25+ ```bash -git clone https://github.com/kanywst/rapg -cd rapg/cmd/rapg -go build . -mv rapg /usr/local/bin +go install github.com/kanywst/rapg/cmd/rapg@latest ``` -## Usage +## Usage Guide -### Basic Usage +### 1. Initialization -Simply, rapg can be run with: +Run `rapg` for the first time to initialize your secure vault. ```bash rapg ``` -### Flags +You will be prompted to **Create a Master Password**. +> **Note:** Choose a strong password (min 12 chars). This password is used to derive your encryption key and is **never stored**. If you lose it, your data is lost forever. -```bash -$ rapg -h +[master] -NAME: - Rapg - rapg is a tool for generating and managing random, strong passwords. - -USAGE: - rapg [global options] command [command options] [arguments...] - -COMMANDS: - add add password - init initialize - show, s show password - list list password - remove, rm remove password - help, h Shows a list of commands or help for one command - -GLOBAL OPTIONS: - --len value, -l value password length (default: 24) - --help, -h show help -``` +### 2. Managing Secrets (TUI) -### Generate random passwords of specified length +Once unlocked, you are in the TUI mode. -You can generate a password of length 100: +- **Navigation**: Use `j`/`k` or `Up`/`Down` arrows. +- **Add Secret**: Press `n`. + - **Service/Username**: Identifiers for your secret. + - **Password**: Leave empty to auto-generate a secure random password. + - **TOTP Secret**: (Optional) Enter your 2FA seed key to generate codes. + - **Env Key**: (Important) The environment variable name (e.g., `DATABASE_PASSWORD`) used for injection. +- **View Details**: Press `Enter` or `Space` to decrypt and view a secret. +- **Copy Password**: Press `Enter` on the detail view. +- **Copy TOTP**: Press `Ctrl+t` to generate and copy the 2FA code. +- **Delete**: Press `d` to delete the selected entry. +- **Quit**: Press `q`. -```bash -rapg -l 100 -``` +### 3. Process Injection (`rapg run`) -### Create a key to encrypt and store the password +This is the core feature. Instead of creating a `.env` file, wrap your command with `rapg run`. -This is the first command you have to run: +Rapg will decrypt secrets that have an **Env Key** set and inject them into the child process environment. ```bash -rapg init -``` +# Inject secrets into your Node.js app +rapg run -- npm start -### Add password with a specific domain and username set +# Inject into Python script +rapg run -- python main.py -Add a password for the user test on twitter.com: - -```bash -rapg add twitter.com/test +# Or any other command +rapg run -- printenv DATABASE_PASSWORD ``` -A password will be generated. +#### Verify with Example Script + +We included a simple Python script in `examples/main.py` to test the injection. -You can also generate and store a password of a specific length. +1. Add a secret in Rapg with Env Key `DATABASE_PASSWORD`. +2. Run the script: ```bash -rapg add twitter.com/test -l 100 +rapg run -- python examples/main.py ``` -### Remove password with a specific domain and username set +> **Security:** The secrets exist only in the memory of the process. They are never written to disk. -Remove a password for the user test on twitter.com: +### 4. Advanced Tools -```bash -rapg remove twitter.com/test -``` +#### Security Audit -### Show the list of passwords +Check if you are reusing passwords across different services. ```bash -rapg list -twitter.com/test +rapg audit ``` -### Displays the stored password +#### Import/Export + +Migrate from other tools or generate a `.env` file if absolutely necessary (e.g., for Docker). ```bash -rapg show twitter.com/test +# Import from CSV +rapg import passwords.csv + +# Export to stdout (can redirect to .env) +rapg export > .env.local ``` -The password will be displayed. +## Security Architecture + +- **Zero-Knowledge**: Master password is never stored. +- **Encryption**: AES-256-GCM. +- **Key Derivation**: Argon2id (RFC 9106). +- **Memory Safety**: Uses `memguard` to protect keys in memory. ## License -rapg released under MIT. See LICENSE for more details. +MIT License - see [LICENSE](LICENSE) for details. diff --git a/TECH.md b/TECH.md new file mode 100644 index 0000000..655ba52 --- /dev/null +++ b/TECH.md @@ -0,0 +1,125 @@ +# Rapg 技術仕様書 (Architecture & Security) + +Rapg は、**Zero-Knowledge (知識ゼロ)** アーキテクチャと **Local-First** の原則に基づき設計されています。 +本ドキュメントでは、そのセキュリティモデル、暗号化フロー、および準拠している国際標準について詳述します。 + +## 1. セキュリティアーキテクチャ + +### 1.1. マスターパスワードとキー導出 (KDF) + +ユーザーのマスターパスワードはディスクに一切保存されません。代わりに、**Argon2id** を使用して暗号化キーを導出し、そのキーのハッシュ値のみを検証用に保存します。 + +```mermaid +sequenceDiagram + participant User + participant App as Rapg (Memory) + participant DB as SQLite (Disk) + + Note over User, DB: 初期化 (Setup) + User->>App: Master Password (P) 入力 + App->>App: Salt (S) 生成 (CSPRNG) + App->>App: Key = Argon2id(P, S) + App->>App: Hash = SHA256(Key) + App->>DB: Save Salt, Hash + Note right of DB: パスワード自体は保存されない + + Note over User, DB: ロック解除 (Unlock) + User->>App: Master Password (P) 入力 + App->>DB: Load Salt (S), Hash (H_stored) + App->>App: Key' = Argon2id(P, S) + App->>App: H_computed = SHA256(Key') + alt H_computed == H_stored + App->>App: SessionKey = Key' (Keep in Memory) + App-->>User: Success + else + App-->>User: Error (Invalid Password) + end +``` + +### 1.2. データ暗号化 (Authenticated Encryption) + +全てのシークレットデータは **AES-256-GCM** を用いて暗号化されます。これにより、機密性(Confidentiality)だけでなく、完全性(Integrity)も保証されます。 + +- **IV/Nonce:** レコードごとに12バイトのランダム値を生成 (NIST SP 800-38D 推奨) +- **Authentication:** GCMモードによる認証タグがデータの改ざんを検知 + +```mermaid +flowchart LR + subgraph Input + Pass[Password] + TOTP[TOTP Secret] + Note[Notes] + Env[EnvKey] + end + + subgraph Process + JSON[JSON Marshal] + GCM[AES-256-GCM Encrypt] + Key[Master Key (Memory)] + Nonce[Random Nonce] + end + + subgraph Storage + DB[(SQLite Blob)] + end + + Input --> JSON + JSON --> GCM + Key --> GCM + Nonce --> GCM + GCM -->|Ciphertext + AuthTag| DB + Nonce -->|Prefix| DB +``` + +--- + +## 2. ソフトウェアアーキテクチャ + +### 2.1. Environment Injection (プロセス分離) + +`rapg run` コマンドは、一時ファイルを作成することなく、メモリ上で子プロセスに環境変数を注入します。 + +```mermaid +sequenceDiagram + participant Rapg as Rapg Process + participant Vault as Encrypted Vault + participant Child as Child Process (e.g. Node, Python) + + Rapg->>Vault: Unlock & Decrypt Secrets + Vault-->>Rapg: Plaintext Secrets (Map) + Rapg->>Rapg: Build Env List (Current Env + Secrets) + Rapg->>Child: syscall.Exec (with new Env) + Note right of Child: 復号された環境変数は
このプロセスのメモリ空間のみに存在 + Child-->>Rapg: Exit Code +``` + +--- + +## 3. 準拠標準・参考文献 (References & Standards) + +Rapgの実装は、以下の RFC (Request for Comments) および NIST 標準規格に準拠しています。 + +### 3.1. 暗号化・ハッシュ関数 + +* **RFC 9106**: *Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications* + * **採用理由:** GPUやASICによる総当たり攻撃への耐性が最強クラスであり、サイドチャネル攻撃に強い `id` モードを採用しています。 + * **Rapg実装:** `golang.org/x/crypto/argon2` +* **NIST SP 800-38D**: *Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM)* + * **採用理由:** 認証付き暗号 (AEAD) のデファクトスタンダードであり、多くのCPUでハードウェアアクセラレーションが効くため高速です。 +* **RFC 5116**: *An Interface and Algorithms for Authenticated Encryption* + * AEADアルゴリズムのインターフェース定義。 + +### 3.2. 多要素認証 (MFA/TOTP) + +* **RFC 6238**: *TOTP: Time-Based One-Time Password Algorithm* + * **採用理由:** Google AuthenticatorやAuthyなど、全ての標準的な2FAアプリと互換性を持たせるため。 + * **Rapg実装:** `github.com/pquerna/otp` +* **RFC 4226**: *HOTP: HMAC-Based One-Time Password Algorithm* + * TOTPの基礎となる規格。 + +### 3.3. セキュリティガイドライン + +* **OWASP Password Storage Cheat Sheet** + * パスワード保存におけるソルトの取り扱い、強力なハッシュ関数の選択(Argon2id)の根拠としています。 +* **Local-First Software** (Ink & Switch) + * 「ユーザーがデータを所有し、クラウドに依存せず、オフラインで動作する」というRapgの設計思想のベースです。 \ No newline at end of file diff --git a/cmd/rapg/main.go b/cmd/rapg/main.go index 04cce29..6f5e92c 100644 --- a/cmd/rapg/main.go +++ b/cmd/rapg/main.go @@ -1,119 +1,267 @@ package main import ( - "log" + "bufio" + "fmt" "os" + "os/exec" "path/filepath" + "strconv" + "strings" + "syscall" - "github.com/kanywst/rapg/internal/out" - "github.com/kanywst/rapg/pkg/rapg/api" - "github.com/urfave/cli" -) - -var ( - homePath, _ = os.UserHomeDir() - keyPath = filepath.Join(homePath, ".rapg", ".key_store") + "github.com/awnumar/memguard" + tea "github.com/charmbracelet/bubbletea" + "github.com/kanywst/rapg/internal/core" + "github.com/kanywst/rapg/internal/storage" + "github.com/kanywst/rapg/internal/ui" + "github.com/spf13/cobra" + "golang.org/x/term" ) func main() { - if _, err := os.Stat(filepath.Join(homePath, ".rapg")); os.IsNotExist(err) { - os.Mkdir(filepath.Join(homePath, ".rapg"), 0755) + memguard.CatchInterrupt() + defer memguard.Purge() + + if err := storage.InitDB(); err != nil { + fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err) + os.Exit(1) } - app := cli.NewApp() - app.Name = "Rapg" - app.Usage = "rapg is a tool for generating and managing random, strong passwords." + rootCmd := &cobra.Command{ + Use: "rapg", + Short: "The Developer-First Secret Manager", + Long: `Rapg is a secure vault for your secrets, designed to replace .env files and unsecure sharing methods.`, + Run: func(cmd *cobra.Command, args []string) { + p := tea.NewProgram(ui.NewModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error running program: %v\n", err) + os.Exit(1) + } + }, + } - app.Flags = []cli.Flag{ - cli.IntFlag{ - Name: "len, l", - Value: 24, - Usage: "password length", + genCmd := &cobra.Command{ + Use: "gen [length]", + Short: "Generate a random password", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + length := 24 + if len(args) > 0 { + val, err := strconv.Atoi(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing length: %v\n", err) + return + } + length = val + } + pass, err := core.GenerateRandomPassword(length) + if err != nil { + fmt.Println("Error:", err) + return + } + fmt.Println(pass) }, } - app.Action = func(c *cli.Context) error { - mrp, err := api.MakeRandomPassword(c.Int("len")) - if err != nil { - log.Fatal(err) - } - out.Green(mrp) - return nil + nukeCmd := &cobra.Command{ + Use: "nuke", + Short: "Delete all data (Destructive)", + Run: func(cmd *cobra.Command, args []string) { + fmt.Print("Are you sure? This will delete all passwords. [y/N]: ") + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading confirmation: %v\n", err) + return + } + response = strings.TrimSpace(strings.ToLower(response)) + + if response == "y" || response == "yes" { + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Error finding home directory: %v\n", err) + return + } + configDir := filepath.Join(home, ".rapg") + + // Delete only database related files + files, err := filepath.Glob(filepath.Join(configDir, "rapg.db*")) + if err != nil { + fmt.Fprintf(os.Stderr, "Error finding database files: %v\n", err) + return + } + for _, f := range files { + if err := os.Remove(f); err != nil { + fmt.Fprintf(os.Stderr, "Error removing file %s: %v\n", f, err) + } + } + fmt.Println("Nuked.") + } else { + fmt.Println("Aborted.") + } + }, } - app.Commands = []cli.Command{ - { - Name: "add", - Usage: "add password", - Action: func(c *cli.Context) error { - if !checkKeyStore() { - out.Green("At first, rapg init") + exportCmd := &cobra.Command{ + Use: "export", + Short: "Export secrets with EnvKey set to .env format", + Run: func(cmd *cobra.Command, args []string) { + unlockVault() + + envVars, err := core.GetEnvVars() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting env vars: %v\n", err) + os.Exit(1) + } + + for k, v := range envVars { + if strings.ContainsAny(v, " \n#=\"") { + escapedV := strings.ReplaceAll(v, "\"", "\\\"") + escapedV = strings.ReplaceAll(escapedV, "\n", "\\n") + fmt.Printf("%s=\"%s\"\n", k, escapedV) } else { - api.AddPassword(c.Args().First(), c.Int("len")) + fmt.Printf("%s=%s\n", k, v) } - return nil - }, - Flags: []cli.Flag{ - cli.IntFlag{ - Name: "len, l", - Value: 24, - }, - }, + } }, - { - Name: "init", - Usage: "initialize", - Action: func(c *cli.Context) error { - api.CreateKey() - return nil - }, - }, - { - Name: "show", - Aliases: []string{"s"}, - Usage: "show password", - Action: func(c *cli.Context) error { - if !checkKeyStore() { - out.Red("At first, rapg init") - } else { - api.ShowPassword(c.Args().First()) + } + + runCmd := &cobra.Command{ + Use: "run -- ", + Short: "Run a command with secrets injected as environment variables", + Long: `Run a command with secrets injected as environment variables. +Note: Secrets configured in Rapg will override any existing environment variables with the same name.`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + unlockVault() + + envVars, err := core.GetEnvVars() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting env vars: %v\n", err) + os.Exit(1) + } + + command := args[0] + cmdArgs := args[1:] + + // #nosec G204 + runCmd := exec.Command(command, cmdArgs...) + runCmd.Stdin = os.Stdin + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + + // Filter existing environment variables to ensure secrets override them correctly + runCmd.Env = make([]string, 0, len(os.Environ())+len(envVars)) + for _, e := range os.Environ() { + key := strings.SplitN(e, "=", 2)[0] + if _, ok := envVars[key]; !ok { + runCmd.Env = append(runCmd.Env, e) } - return nil - }, + } + + // Append secrets + for k, v := range envVars { + runCmd.Env = append(runCmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + + if err := runCmd.Run(); err != nil { + // Try to pass through the exit code + if exitError, ok := err.(*exec.ExitError); ok { + if status, ok := exitError.Sys().(syscall.WaitStatus); ok { + os.Exit(status.ExitStatus()) + } + } + fmt.Fprintf(os.Stderr, "Command execution failed: %v\n", err) + os.Exit(1) + } }, - { - Name: "list", - Usage: "list password", - Action: func(c *cli.Context) error { - api.ShowList() - return nil - }, + } + + // New: Import Command + importCmd := &cobra.Command{ + Use: "import [csv_file]", + Short: "Import passwords from a CSV file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + file, err := os.Open(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening file: %v\n", err) + os.Exit(1) + } + defer file.Close() + + unlockVault() + + count, err := core.ImportCSV(file) + if err != nil { + fmt.Fprintf(os.Stderr, "Import failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("Successfully imported %d passwords.\n", count) }, - { - Name: "remove", - Aliases: []string{"rm"}, - Usage: "remove password", - Action: func(c *cli.Context) error { - if !checkKeyStore() { - out.Red("At first, rapg init") - } else { - api.RemovePassword(c.Args().First()) + } + + // New: Audit Command + auditCmd := &cobra.Command{ + Use: "audit", + Short: "Check for reused passwords", + Run: func(cmd *cobra.Command, args []string) { + unlockVault() + + results, err := core.AuditPasswords() + if err != nil { + fmt.Fprintf(os.Stderr, "Audit failed: %v\n", err) + os.Exit(1) + } + + if len(results) == 0 { + fmt.Println("✅ No reused passwords found. Good job!") + return + } + + fmt.Println("⚠️ Reuse Detected! The following passwords are used in multiple places:") + for _, r := range results { + fmt.Printf("\nPassword used %d times:\n", r.Count) + for _, svc := range r.Services { + fmt.Printf(" - %s\n", svc) } - return nil - }, + } + fmt.Println("\nTip: Use 'rapg gen' to replace them with unique passwords.") }, } - app.Run(os.Args) + rootCmd.AddCommand(genCmd, nukeCmd, exportCmd, runCmd, importCmd, auditCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } } -func checkKeyStore() bool { - _, err := os.OpenFile(keyPath, os.O_RDONLY, 0) +func unlockVault() { + if !core.IsInitialized() { + fmt.Fprintln(os.Stderr, "Vault not initialized. Run 'rapg' first.") + os.Exit(1) + } + + fmt.Fprint(os.Stderr, "Master Password: ") + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stderr) if err != nil { - if os.IsNotExist(err) { - return false - } - panic(err) + fmt.Fprintf(os.Stderr, "Error reading password: %v\n", err) + os.Exit(1) + } + + // Immediately move password to a protected buffer and wipe original slice. + passwordBuffer := memguard.NewBufferFromBytes(bytePassword) + defer passwordBuffer.Destroy() + for i := range bytePassword { + bytePassword[i] = 0 + } + + if err := core.UnlockVault(passwordBuffer.Bytes()); err != nil { + fmt.Fprintln(os.Stderr, "Invalid password.") + os.Exit(1) } - return true } diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..295d820 Binary files /dev/null and b/demo.gif differ diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..709c2c8 --- /dev/null +++ b/demo.tape @@ -0,0 +1,109 @@ +# Where should we write the GIF? +Output demo.gif + +# Set up a 1200x600 terminal with 46px font. +Set FontSize 22 +Set Width 1200 +Set Height 800 +Set TypingSpeed 75ms + +# Cleanup and Build (Hidden) +Hide +Type "rm -rf ~/.rapg" +Enter +Type "go build -o rapg cmd/rapg/main.go" +Enter +Ctrl+l +Sleep 500ms +Show + +# 1. Start Rapg +Type "./rapg" +Sleep 500ms +Enter + +# Wait for the UI to start and show "SETUP VAULT" clearly +Sleep 2.5s + +# 2. Initial Setup +# Use a strong password to satisfy zxcvbn requirements +Type "purple-monkey-dishwasher-99" +Sleep 1s +Enter +Sleep 2s + +# 3. Add a Secret with Env Injection +Type "n" +Sleep 1.5s +Type "Production DB" +Enter +Sleep 1s +Type "admin" +Enter +Sleep 1s +# Leave password blank for generation (Index 2) +Enter +Sleep 1s +# No TOTP (Index 3) +Enter +Sleep 1s +# Set Env Key (Index 4) +Type "DB_PASSWORD" +Enter +Sleep 1s +# Notes (Index 5) - Enter to Submit +Type "Production DB Password" +Sleep 1s +Enter +Enter +# Wait for list to update and flash message +Sleep 3s + +# 4. Add a Secret with TOTP +Type "n" +Sleep 1.5s +Type "google.com" +Enter +Sleep 1s +Type "kanywst@gmail.com" +Enter +Sleep 1s +# Leave password blank +Enter +Sleep 1s +# Set TOTP Secret +Type "JBSWY3DPEHPK3PXP" +Enter +Sleep 1s +# No Env Key +Enter +Sleep 1s +# No Notes (Submit) +Enter +Sleep 3s + +# 5. View Details (Google) +# "google.com" should be the second item. Move down. +Type "j" +Sleep 1s +# Enter to View Details (and copy password) +Enter +Sleep 2s + +# Copy TOTP +Ctrl+T +Sleep 1.5s + +# 6. Exit TUI +Type "q" +Sleep 1s + +# 7. Demonstrate Process Injection +# Use single quotes to prevent the outer shell from expanding the variable +Type "./rapg run -- sh -c 'echo Injected: $DB_PASSWORD'" +Enter +# Wait for "Master Password: " prompt to appear and echo to be disabled +Sleep 2s +Type "purple-monkey-dishwasher-99" +Enter +Sleep 4s diff --git a/examples/main.py b/examples/main.py new file mode 100644 index 0000000..1e46010 --- /dev/null +++ b/examples/main.py @@ -0,0 +1,28 @@ +import os +import sys + +def main(): + print("--- Rapg Injection Test ---") + + # The keys you configured in Rapg (e.g., DATABASE_URL) + target_keys = ["DATABASE_URL", "API_KEY"] + + found = False + for key in target_keys: + val = os.environ.get(key) + if val: + print(f"✅ {key} is injected!") + print(f" Value: {val}") + found = True + else: + print(f"❌ {key} is missing.") + + if not found: + print("\nNo secrets found. Make sure you set the 'Env Key' field in Rapg.") + sys.exit(1) + else: + print("\nSuccess! The process can read your secrets.") + +if __name__ == "__main__": + main() + diff --git a/go.mod b/go.mod index f2b5f9f..afd17c5 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,50 @@ module github.com/kanywst/rapg -go 1.22 +go 1.25 require ( - github.com/fatih/color v1.16.0 - github.com/jinzhu/gorm v1.9.16 - github.com/urfave/cli v1.22.12 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/pquerna/otp v1.5.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.47.0 + golang.org/x/term v0.39.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/awnumar/memcall v0.4.0 // indirect + github.com/awnumar/memguard v0.23.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/boombuler/barcode v1.1.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.4 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.7.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.16 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - golang.org/x/sys v0.14.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index b023ff8..8c9638a 100644 --- a/go.sum +++ b/go.sum @@ -1,68 +1,108 @@ -github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= -github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= +github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= +github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= +github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= +github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= +github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= +github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= +github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw= +github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= -github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= -github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= -github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= -github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= -github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= -github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 h1:4kuARK6Y6FxaNu/BnU2OAaLF86eTVhP2hjTB6iMvItA= +github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/urfave/cli v1.22.12 h1:igJgVw1JdKH+trcLWLeLwZjU9fEfPesQ+9/e4MQ44S8= -github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= -golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/core/core.go b/internal/core/core.go new file mode 100644 index 0000000..0d56b23 --- /dev/null +++ b/internal/core/core.go @@ -0,0 +1,337 @@ +package core + +import ( + "crypto/rand" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "os" + "strconv" + "strings" + + "github.com/awnumar/memguard" + "github.com/kanywst/rapg/internal/crypto" + "github.com/kanywst/rapg/internal/storage" +) + +var ( + // SessionKey holds the decrypted master key in protected memory. + SessionKey *memguard.LockedBuffer +) + +// IsInitialized checks if the vault has been set up. +func IsInitialized() bool { + _, err := storage.GetMeta("salt") + return err == nil +} + +// InitializeVault sets up a new master password. +func InitializeVault(password []byte) error { + salt, err := crypto.GenerateSalt() + if err != nil { + return err + } + + params := crypto.DefaultKDFParams() + keyBuf := crypto.DeriveKey(password, salt, params) + // We don't defer Destroy() here because we transfer ownership to SessionKey at the end. + // If an error occurs, we must destroy it manually. + + hash := crypto.HashKey(keyBuf.Bytes()) + + if err := storage.SaveMeta("salt", salt); err != nil { + keyBuf.Destroy() + return err + } + if err := storage.SaveMeta("validation", hash); err != nil { + keyBuf.Destroy() + return err + } + if err := storage.SaveMeta("argon_time", []byte(fmt.Sprintf("%d", params.Time))); err != nil { + keyBuf.Destroy() + return err + } + if err := storage.SaveMeta("argon_memory", []byte(fmt.Sprintf("%d", params.Memory))); err != nil { + keyBuf.Destroy() + return err + } + if err := storage.SaveMeta("argon_threads", []byte(fmt.Sprintf("%d", params.Threads))); err != nil { + keyBuf.Destroy() + return err + } + + // Move key to protected memory (SessionKey takes ownership) + if SessionKey != nil { + SessionKey.Destroy() + } + SessionKey = keyBuf + return nil +} + +// UnlockVault attempts to derive the key and verify it. +func UnlockVault(password []byte) error { + salt, err := storage.GetMeta("salt") + if err != nil { + return errors.New("vault not initialized") + } + + expectedHash, err := storage.GetMeta("validation") + if err != nil { + return errors.New("vault corruption: missing validation hash") + } + + // Load KDF params from storage, falling back to defaults if not found + params := crypto.DefaultKDFParams() + if t, err := storage.GetMeta("argon_time"); err == nil { + if val, err := strconv.ParseUint(string(t), 10, 32); err == nil { + params.Time = uint32(val) + } + } + if m, err := storage.GetMeta("argon_memory"); err == nil { + if val, err := strconv.ParseUint(string(m), 10, 32); err == nil { + params.Memory = uint32(val) + } + } + if th, err := storage.GetMeta("argon_threads"); err == nil { + if val, err := strconv.ParseUint(string(th), 10, 8); err == nil { + params.Threads = uint8(val) + } + } + + keyBuf := crypto.DeriveKey(password, salt, params) + // If verification fails, we destroy the buffer. + // If success, we transfer ownership to SessionKey. + + if !crypto.VerifyKey(keyBuf.Bytes(), expectedHash) { + keyBuf.Destroy() + return errors.New("invalid password") + } + + if SessionKey != nil { + SessionKey.Destroy() + } + SessionKey = keyBuf + return nil +} + +// LockVault destroys the session key. +func LockVault() { + if SessionKey != nil { + SessionKey.Destroy() + SessionKey = nil + } +} + +// GenerateRandomPassword creates a cryptographically secure random password. +func GenerateRandomPassword(length int) (string, error) { + const letters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_=+[]{}|;:,.<>?" + ret := make([]byte, length) + for i := 0; i < length; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + return string(ret), nil +} + +// AddEntry encrypts and stores a password. +func AddEntry(service, username string, data storage.SecretData) error { + if SessionKey == nil { + return errors.New("vault locked") + } + return storage.Create(service, username, data, SessionKey.Bytes(), crypto.EncryptAESGCM) +} + +// GetEntry returns the decrypted secret data. +func GetEntry(entry storage.PasswordEntry) (*storage.SecretData, error) { + if SessionKey == nil { + return nil, errors.New("vault locked") + } + + // SessionKey.Bytes() returns a slice referencing the protected memory. + // It is only valid as long as SessionKey is not destroyed. + decrypted, err := crypto.DecryptAESGCM(entry.Cipher, SessionKey.Bytes()) + if err != nil { + return nil, err + } + + var data storage.SecretData + // Try to unmarshal as JSON first for new format. + if err := json.Unmarshal(decrypted, &data); err != nil { + // If unmarshal fails, assume it's a legacy plaintext password. + data = storage.SecretData{Password: string(decrypted)} + } + + return &data, nil +} + +// GetEnvVars retrieves all secrets that have an EnvKey set. +func GetEnvVars() (map[string]string, error) { + if SessionKey == nil { + return nil, errors.New("vault locked") + } + + entries, err := storage.List() + if err != nil { + return nil, err + } + + envVars := make(map[string]string) + for _, entry := range entries { + secret, err := GetEntry(entry) + if err != nil { + continue // Skip corrupted entries + } + if secret.EnvKey != "" { + envVars[secret.EnvKey] = secret.Password + } + } + return envVars, nil +} + +func ListEntries() ([]storage.PasswordEntry, error) { + return storage.List() +} + +func DeleteEntry(id uint) error { + return storage.Delete(id) +} + +// ImportCSV reads a CSV file and imports passwords. +func ImportCSV(r io.Reader) (int, error) { + if SessionKey == nil { + return 0, errors.New("vault locked") + } + + reader := csv.NewReader(r) + header, err := reader.Read() + if err != nil { + return 0, err + } + + colMap := make(map[string]int) + for i, h := range header { + h = strings.ToLower(strings.TrimSpace(h)) + switch h { + case "service", "url", "name", "title", "app": + if _, ok := colMap["service"]; !ok { + colMap["service"] = i + } + case "username", "user", "login", "email": + if _, ok := colMap["username"]; !ok { + colMap["username"] = i + } + case "password", "pass", "secret": + if _, ok := colMap["password"]; !ok { + colMap["password"] = i + } + case "notes", "note", "description", "comment": + if _, ok := colMap["notes"]; !ok { + colMap["notes"] = i + } + } + } + + if _, ok := colMap["service"]; !ok { + return 0, errors.New("could not find 'service', 'url', or 'name' column in CSV header") + } + if _, ok := colMap["password"]; !ok { + return 0, errors.New("could not find 'password' column in CSV header") + } + + count := 0 + rowNumber := 0 + var importError error + for { + record, err := reader.Read() + if err == io.EOF { + break + } + rowNumber++ + if err != nil { + fmt.Fprintf(os.Stderr, "Skipping malformed CSV row %d: %v\n", rowNumber, err) + importError = errors.New("one or more rows failed to parse") + continue + } + + service := record[colMap["service"]] + pass := record[colMap["password"]] + + username := "" + if idx, ok := colMap["username"]; ok && idx < len(record) { + username = record[idx] + } + if username == "" { + username = fmt.Sprintf("imported-%d", rowNumber) + } + + notes := "" + if idx, ok := colMap["notes"]; ok && idx < len(record) { + notes = record[idx] + } + + data := storage.SecretData{ + Password: pass, + Notes: notes, + } + + if err := AddEntry(service, username, data); err != nil { + fmt.Fprintf(os.Stderr, "Failed to import %s: %v\n", service, err) + importError = errors.New("one or more entries failed to import") + } else { + count++ + } + } + + return count, importError +} + +type AuditResult struct { + Password string + Count int + Services []string +} + +// AuditPasswords checks for reused passwords across the vault. +// It uses a map to achieve O(n) performance, accepting the temporary memory overhead +// of holding plaintext passwords during the audit process. +func AuditPasswords() ([]AuditResult, error) { + if SessionKey == nil { + return nil, errors.New("vault locked") + } + + entries, err := storage.List() + if err != nil { + return nil, err + } + + // Map of password to list of services using it. + passwordToServices := make(map[string][]string) + + for _, entry := range entries { + secret, err := GetEntry(entry) + if err != nil || secret.Password == "" { + continue // Skip corrupted or empty passwords + } + serviceInfo := fmt.Sprintf("%s (%s)", entry.Service, entry.Username) + passwordToServices[secret.Password] = append(passwordToServices[secret.Password], serviceInfo) + } + + var results []AuditResult + for pass, services := range passwordToServices { + if len(services) > 1 { + results = append(results, AuditResult{ + Password: pass, + Count: len(services), + Services: services, + }) + } + } + + return results, nil +} diff --git a/internal/core/core_test.go b/internal/core/core_test.go new file mode 100644 index 0000000..e325eb5 --- /dev/null +++ b/internal/core/core_test.go @@ -0,0 +1,199 @@ +package core + +import ( + "os" + "testing" + + "github.com/kanywst/rapg/internal/crypto" + "github.com/kanywst/rapg/internal/storage" +) + +func setupCoreTest(t *testing.T) func() { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + + if err := storage.InitDB(); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + + // Reset SessionKey + if SessionKey != nil { + SessionKey.Destroy() + SessionKey = nil + } + + return func() { + os.Setenv("HOME", originalHome) + if SessionKey != nil { + SessionKey.Destroy() + SessionKey = nil + } + } +} + +func TestVaultLifecycle(t *testing.T) { + cleanup := setupCoreTest(t) + defer cleanup() + + password := "masterpass" + + // 1. Initialize + if IsInitialized() { + t.Error("Vault should not be initialized initially") + } + + if err := InitializeVault([]byte(password)); err != nil { + t.Fatalf("InitializeVault failed: %v", err) + } + + if !IsInitialized() { + t.Error("Vault should be initialized after InitializeVault") + } + + if SessionKey == nil { + t.Error("SessionKey should be set after initialization") + } + + // 2. Lock (Simulate by clearing key) + SessionKey = nil + + // 3. Unlock with wrong password + if err := UnlockVault([]byte("wrongpass")); err == nil { + t.Error("UnlockVault should fail with wrong password") + } + + // 4. Unlock with correct password + if err := UnlockVault([]byte(password)); err != nil { + t.Fatalf("UnlockVault failed with correct password: %v", err) + } + + if SessionKey == nil { + t.Error("SessionKey should be restored after unlock") + } +} + +func TestEntryManagement(t *testing.T) { + cleanup := setupCoreTest(t) + defer cleanup() + + InitializeVault([]byte("masterpass")) + + svc := "github" + user := "octocat" + pass := "secret123" + + data := storage.SecretData{ + Password: pass, + Notes: "my notes", + } + + // Add + if err := AddEntry(svc, user, data); err != nil { + t.Fatalf("AddEntry failed: %v", err) + } + + // Verify storage directly (optional, but good for integration check) + entries, _ := ListEntries() + if len(entries) != 1 { + t.Fatalf("Expected 1 entry, got %d", len(entries)) + } + + // Get (Decrypt) + retrieved, err := GetEntry(entries[0]) + if err != nil { + t.Fatalf("GetEntry failed: %v", err) + } + + if retrieved.Password != pass { + t.Errorf("Decrypted password mismatch. Got %s, want %s", retrieved.Password, pass) + } + if retrieved.Notes != "my notes" { + t.Errorf("Decrypted notes mismatch") + } +} + +func TestEnvVars(t *testing.T) { + cleanup := setupCoreTest(t) + defer cleanup() + InitializeVault([]byte("p")) + + AddEntry("db", "user", storage.SecretData{Password: "postgres://...", EnvKey: "DATABASE_URL"}) + AddEntry("api", "key", storage.SecretData{Password: "12345", EnvKey: "API_KEY"}) + AddEntry("other", "foo", storage.SecretData{Password: "ignored", EnvKey: ""}) + + vars, err := GetEnvVars() + if err != nil { + t.Fatalf("GetEnvVars failed: %v", err) + } + + if len(vars) != 2 { + t.Errorf("Expected 2 env vars, got %d", len(vars)) + } + if vars["DATABASE_URL"] != "postgres://..." { + t.Error("DATABASE_URL mismatch") + } +} + +func TestGenerateRandomPassword(t *testing.T) { + p1, err := GenerateRandomPassword(16) + if err != nil { + t.Fatalf("GenerateRandomPassword failed: %v", err) + } + if len(p1) != 16 { + t.Errorf("Expected length 16, got %d", len(p1)) + } + + p2, _ := GenerateRandomPassword(16) + if p1 == p2 { + t.Error("Random passwords should not be identical") + } +} + +func TestLegacyFallback(t *testing.T) { + cleanup := setupCoreTest(t) + defer cleanup() + InitializeVault([]byte("p")) + + // 1. Valid JSON (New format) + AddEntry("new", "u", storage.SecretData{Password: "pass123"}) + + // 2. Plaintext (Legacy format) - Mock by bypassing AddEntry + // Since AddEntry always marshals to JSON, we need to manually create an entry in DB + mockKey := SessionKey.Bytes() + fromCrypto, _ := crypto.EncryptAESGCM([]byte("legacy-pass"), mockKey) + storage.DB.Create(&storage.PasswordEntry{ + Service: "legacy", + Cipher: fromCrypto, + }) + + // 3. Valid JSON but wrong schema (e.g. legacy pass that happens to be JSON) + // This will now NOT fallback, which is the intended robust behavior. + // 4. New format with empty password + AddEntry("empty", "u", storage.SecretData{Password: ""}) + + entries, _ := ListEntries() + + for _, e := range entries { + ret, err := GetEntry(e) + if err != nil { + t.Errorf("GetEntry failed for %s: %v", e.Service, err) + continue + } + + switch e.Service { + case "new": + if ret.Password != "pass123" { + t.Errorf("New format failed: got %s", ret.Password) + } + case "legacy": + if ret.Password != "legacy-pass" { + t.Errorf("Legacy format failed: got %s", ret.Password) + } + case "empty": + if ret.Password != "" { + t.Errorf("Empty password failed: got %s", ret.Password) + } + } + } +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index f3a86c9..e8897c1 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -4,40 +4,115 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "errors" "io" + + "github.com/awnumar/memguard" + "golang.org/x/crypto/argon2" +) + +// Config for Argon2id +const ( + DefaultArgonTime = 3 + DefaultArgonMemory = 128 * 1024 + DefaultArgonThreads = 4 + keyLen = 32 ) -func EncryptAES(text, key, commonIV []byte) ([]byte, error) { +// KDFParams holds the parameters for Argon2id. +type KDFParams struct { + Time uint32 + Memory uint32 + Threads uint8 +} + +// DefaultKDFParams returns the current recommended parameters. +func DefaultKDFParams() KDFParams { + return KDFParams{ + Time: DefaultArgonTime, + Memory: DefaultArgonMemory, + Threads: DefaultArgonThreads, + } +} + +// DeriveKey generates a 32-byte key from a master password and salt using Argon2id. +// It returns the key in a LockedBuffer for security. +func DeriveKey(password []byte, salt []byte, params KDFParams) *memguard.LockedBuffer { + key := argon2.IDKey(password, salt, params.Time, params.Memory, params.Threads, keyLen) + return memguard.NewBufferFromBytes(key) +} + +// GenerateSalt creates a random salt. +func GenerateSalt() ([]byte, error) { + salt := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, err + } + return salt, nil +} + +// EncryptAESGCM encrypts data using AES-256-GCM. +func EncryptAESGCM(plaintext, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } - cfb := cipher.NewCFBEncrypter(block, commonIV) - ciphertext := make([]byte, len(text)) - cfb.XORKeyStream(ciphertext, text) + 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 ciphertext, nil } -func DecryptAES(ciphertext, key, commonIV []byte) ([]byte, error) { +// DecryptAESGCM decrypts data using AES-256-GCM. +func DecryptAESGCM(ciphertext, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } - cfbdec := cipher.NewCFBDecrypter(block, commonIV) - plaintext := make([]byte, len(ciphertext)) - cfbdec.XORKeyStream(plaintext, ciphertext) + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } - return plaintext, nil -} + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, errors.New("ciphertext too short") + } -func GenerateAESKey() ([]byte, error) { - key := make([]byte, 32) - _, err := io.ReadFull(rand.Reader, key) + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err } - return key, nil + + return plaintext, nil +} + +// VerifyMasterPassword checks if the derived key matches the stored validation hash. +// This allows us to check if the password is correct without storing the password or key. +func VerifyKey(key []byte, storedHash []byte) bool { + h := sha256.Sum256(key) + // Use constant-time comparison to prevent timing attacks. + if len(h) != len(storedHash) { + return false + } + return subtle.ConstantTimeCompare(h[:], storedHash) == 1 +} + +// HashKey creates a verification hash of the key. +func HashKey(key []byte) []byte { + h := sha256.Sum256(key) + return h[:] } diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..16ec363 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,87 @@ +package crypto + +import ( + "bytes" + "testing" +) + +func TestDeriveKey(t *testing.T) { + password := "mysecretpassword" + salt, err := GenerateSalt() + if err != nil { + t.Fatalf("GenerateSalt failed: %v", err) + } + + keyBuf1 := DeriveKey([]byte(password), salt, DefaultKDFParams()) + defer keyBuf1.Destroy() + key1 := keyBuf1.Bytes() + + keyBuf2 := DeriveKey([]byte(password), salt, DefaultKDFParams()) + defer keyBuf2.Destroy() + key2 := keyBuf2.Bytes() + + if !bytes.Equal(key1, key2) { + t.Error("DeriveKey should be deterministic with same inputs") + } + + salt2, _ := GenerateSalt() + keyBuf3 := DeriveKey([]byte(password), salt2, DefaultKDFParams()) + defer keyBuf3.Destroy() + key3 := keyBuf3.Bytes() + + if bytes.Equal(key1, key3) { + t.Error("DeriveKey should produce different keys for different salts") + } +} + +func TestEncryptDecrypt(t *testing.T) { + key := make([]byte, 32) + // simple key for testing + copy(key, []byte("12345678901234567890123456789012")) + + plaintext := []byte("secret data") + + ciphertext, err := EncryptAESGCM(plaintext, key) + if err != nil { + t.Fatalf("EncryptAESGCM failed: %v", err) + } + + decrypted, err := DecryptAESGCM(ciphertext, key) + if err != nil { + t.Fatalf("DecryptAESGCM failed: %v", err) + } + + if !bytes.Equal(plaintext, decrypted) { + t.Errorf("Decrypted data does not match plaintext. Got %s, want %s", decrypted, plaintext) + } +} + +func TestEncryptDecrypt_InvalidKey(t *testing.T) { + key := make([]byte, 32) + copy(key, []byte("12345678901234567890123456789012")) + plaintext := []byte("data") + + ciphertext, _ := EncryptAESGCM(plaintext, key) + + wrongKey := make([]byte, 32) + copy(wrongKey, []byte("22345678901234567890123456789012")) + + _, err := DecryptAESGCM(ciphertext, wrongKey) + if err == nil { + t.Error("Decrypt should fail with wrong key") + } +} + +func TestVerifyKey(t *testing.T) { + key := []byte("somekey") + hash := HashKey(key) + + if !VerifyKey(key, hash) { + t.Error("VerifyKey should return true for matching key and hash") + } + + wrongKey := []byte("otherkey") + if VerifyKey(wrongKey, hash) { + t.Error("VerifyKey should return false for non-matching key") + } +} diff --git a/internal/out/cprint.go b/internal/out/cprint.go deleted file mode 100644 index aac33c6..0000000 --- a/internal/out/cprint.go +++ /dev/null @@ -1,45 +0,0 @@ -package out - -import ( - "fmt" - - "github.com/fatih/color" -) - -var ( - red = color.New(color.FgRed).SprintFunc() - yellow = color.New(color.FgYellow).SprintFunc() - green = color.New(color.FgGreen).SprintFunc() - blue = color.New(color.FgBlue).SprintFunc() - cyan = color.New(color.FgCyan).SprintFunc() - magenta = color.New(color.FgMagenta).SprintFunc() - white = color.New(color.FgWhite).SprintFunc() -) - -func Red(pass string) { - fmt.Printf("%s\n", red(pass)) -} - -func Yellow(pass string) { - fmt.Printf("%s\n", yellow(pass)) -} - -func Green(pass string) { - fmt.Printf("%s\n", green(pass)) -} - -func Blue(pass string) { - fmt.Printf("%s\n", blue(pass)) -} - -func Cyan(pass string) { - fmt.Printf("%s\n", cyan(pass)) -} - -func Magenta(pass string) { - fmt.Printf("%s\n", magenta(pass)) -} - -func White(pass string) { - fmt.Printf("%s\n", white(pass)) -} diff --git a/internal/storage/db.go b/internal/storage/db.go new file mode 100644 index 0000000..ea9f11c --- /dev/null +++ b/internal/storage/db.go @@ -0,0 +1,117 @@ +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// SecretData is the structure that gets serialized and encrypted. +type SecretData struct { + Password string `json:"password"` + TOTP string `json:"totp,omitempty"` + Notes string `json:"notes,omitempty"` + Url string `json:"url,omitempty"` + EnvKey string `json:"env_key,omitempty"` +} + +type PasswordEntry struct { + gorm.Model + Service string `gorm:"uniqueIndex:idx_service_username"` + Username string `gorm:"uniqueIndex:idx_service_username"` + // Cipher contains the encrypted JSON of SecretData + Cipher []byte +} + +type Meta struct { + Key string `gorm:"primaryKey"` + Value []byte +} + +var DB *gorm.DB + +func InitDB() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + configDir := filepath.Join(home, ".rapg") + if err := os.MkdirAll(configDir, 0700); err != nil { + return err + } + + dbPath := filepath.Join(configDir, "rapg.db") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + if err := db.AutoMigrate(&PasswordEntry{}, &Meta{}); err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + + DB = db + return nil +} + +// Meta Operations (for Salt and Validation Hash) + +func SaveMeta(key string, value []byte) error { + return DB.Save(&Meta{Key: key, Value: value}).Error +} + +func GetMeta(key string) ([]byte, error) { + var m Meta + if err := DB.First(&m, "key = ?", key).Error; err != nil { + return nil, err + } + return m.Value, nil +} + +// Entry Operations + +func Create(service, username string, secret SecretData, key []byte, encryptFunc func([]byte, []byte) ([]byte, error)) error { + jsonData, err := json.Marshal(secret) + if err != nil { + return err + } + + encrypted, err := encryptFunc(jsonData, key) + if err != nil { + return err + } + + entry := PasswordEntry{ + Service: service, + Username: username, + Cipher: encrypted, + } + return DB.Create(&entry).Error +} + +func List() ([]PasswordEntry, error) { + var entries []PasswordEntry + result := DB.Find(&entries) + return entries, result.Error +} + +func Delete(id uint) error { + return DB.Delete(&PasswordEntry{}, id).Error +} + +func Find(service, username string) (*PasswordEntry, error) { + var entry PasswordEntry + if err := DB.Where("service = ? AND username = ?", service, username).First(&entry).Error; err != nil { + return nil, err + } + return &entry, nil +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..ccd225a --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,99 @@ +package storage + +import ( + "os" + "testing" +) + +// setupTestDB creates a temporary directory, sets HOME to it, and initializes the DB. +// It returns a cleanup function that should be deferred. +func setupTestDB(t *testing.T) func() { + tmpDir := t.TempDir() + + // Mock HOME to point to temp dir + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + + if err := InitDB(); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + + return func() { + os.Setenv("HOME", originalHome) + // DB cleanup if necessary (GORM usually handles connection pooling, but file cleanup is done by t.TempDir) + } +} + +func TestMetaOperations(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + key := "test_key" + value := []byte("test_value") + + if err := SaveMeta(key, value); err != nil { + t.Fatalf("SaveMeta failed: %v", err) + } + + retrieved, err := GetMeta(key) + if err != nil { + t.Fatalf("GetMeta failed: %v", err) + } + + if string(retrieved) != string(value) { + t.Errorf("GetMeta returned %s, want %s", retrieved, value) + } + + _, err = GetMeta("non_existent") + if err == nil { + t.Error("GetMeta should fail for non-existent key") + } +} + +func TestEntryOperations(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Mock encrypt function + mockEncrypt := func(data, key []byte) ([]byte, error) { + return data, nil // No actual encryption for storage test + } + + svc := "google" + user := "test@example.com" + secret := SecretData{Password: "password123"} + dummyKey := []byte("dummy") + + // Test Create + if err := Create(svc, user, secret, dummyKey, mockEncrypt); err != nil { + t.Fatalf("Create failed: %v", err) + } + + // Test Find + entry, err := Find(svc, user) + if err != nil { + t.Fatalf("Find failed: %v", err) + } + if entry.Service != svc || entry.Username != user { + t.Errorf("Find returned incorrect entry: %+v", entry) + } + + // Test List + entries, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(entries) != 1 { + t.Errorf("List should return 1 entry, got %d", len(entries)) + } + + // Test Delete + if err := Delete(entry.ID); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + entries, _ = List() + if len(entries) != 0 { + t.Errorf("List should return 0 entries after delete, got %d", len(entries)) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..9a3112f --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,504 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kanywst/rapg/internal/core" + "github.com/kanywst/rapg/internal/storage" + "github.com/nbutton23/zxcvbn-go" + "github.com/pquerna/otp/totp" +) + +// Styles +var ( + appStyle = lipgloss.NewStyle().Padding(1, 2) + + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#25A065")). + Padding(0, 1) + + statusMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")) + + detailStyle = lipgloss.NewStyle(). + PaddingLeft(2). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.Color("#3C3C3C")) + + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D7D7D")) + valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + totpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00A0FF")).Bold(true) +) + +type sessionState int + +const ( + stateInit sessionState = iota + stateLogin + stateVault + stateAdd +) + +type item struct { + id uint + service string + username string +} + +func (i item) Title() string { return i.service } +func (i item) Description() string { return i.username } +func (i item) FilterValue() string { return i.service + " " + i.username } + +type Model struct { + state sessionState + list list.Model + inputs []textinput.Model + focusedInput int + statusMessage string + errorMessage string + windowWidth int + windowHeight int + + // Detail View + selectedSecret *storage.SecretData + viewport viewport.Model + + // Login/Init Input + authInput textinput.Model +} + +func NewModel() Model { + // Vault List + delegate := list.NewDefaultDelegate() + l := list.New([]list.Item{}, delegate, 0, 0) + l.Title = "Rapg Vault" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = titleStyle + + // Add Form Inputs + inputs := make([]textinput.Model, 6) + labels := []string{"Service", "Username", "Password (Empty to Gen)", "TOTP Secret (Optional)", "Env Key (e.g. DATABASE_URL)", "Notes"} + + for i := range inputs { + inputs[i] = textinput.New() + inputs[i].Placeholder = labels[i] + inputs[i].CharLimit = 200 + inputs[i].Width = 30 + } + inputs[2].EchoMode = textinput.EchoPassword // Password + + // Auth Input + ai := textinput.New() + ai.Placeholder = "Master Password" + ai.EchoMode = textinput.EchoPassword + ai.Focus() + ai.Width = 30 + + initialState := stateLogin + if !core.IsInitialized() { + initialState = stateInit + ai.Placeholder = "Create Master Password" + } + + return Model{ + state: initialState, + list: l, + inputs: inputs, + authInput: ai, + viewport: viewport.New(0, 0), + } +} + +func loadItems() []list.Item { + entries, err := core.ListEntries() + if err != nil { + return []list.Item{} + } + items := make([]list.Item, len(entries)) + for i, e := range entries { + items[i] = item{id: e.ID, service: e.Service, username: e.Username} + } + return items +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(textinput.Blink, tickCmd()) +} + +// Tick for TOTP updates +type tickMsg time.Time + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowWidth = msg.Width + m.windowHeight = msg.Height + + // Split View Layout + listWidth := msg.Width / 3 + if listWidth < 30 { + listWidth = 30 + } + + m.list.SetSize(listWidth, msg.Height-4) + m.viewport.Width = msg.Width - listWidth - 6 + m.viewport.Height = msg.Height - 4 + + case tickMsg: + // Refresh detail view for TOTP + if m.state == stateVault && m.selectedSecret != nil && m.selectedSecret.TOTP != "" { + m.updateDetailView() + } + cmds = append(cmds, tickCmd()) + + case tea.KeyMsg: + // Global Quits + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + switch m.state { + case stateInit: + switch msg.String() { + case "enter": + pass := m.authInput.Value() + strength := zxcvbn.PasswordStrength(pass, nil) + if len(pass) < 12 { + m.errorMessage = "Password must be at least 12 characters" + } else if strength.Score < 3 { + m.errorMessage = fmt.Sprintf("Password too weak (Score: %d/4). Use a more complex passphrase.", strength.Score) + } else { + if err := core.InitializeVault([]byte(pass)); err != nil { + m.errorMessage = err.Error() + } else { + m.state = stateVault + m.authInput.SetValue("") + m.list.SetItems(loadItems()) + } + } + } + case stateLogin: + switch msg.String() { + case "enter": + pass := m.authInput.Value() + if err := core.UnlockVault([]byte(pass)); err != nil { + m.errorMessage = "Invalid Password" + m.authInput.SetValue("") + } else { + m.state = stateVault + m.errorMessage = "" + m.authInput.SetValue("") + m.list.SetItems(loadItems()) + } + } + + case stateVault: + // If filtering, list handles keys + if m.list.FilterState() == list.Filtering { + break + } + + switch msg.String() { + case "q": + return m, tea.Quit + case "n": + m.state = stateAdd + m.resetInputs() + return m, nil + + // Navigation + case "up", "k", "down", "j": + m.list, cmd = m.list.Update(msg) + // Clear detail view on navigation to avoid lag from decryption + m.selectedSecret = nil + m.viewport.SetContent("") + return m, cmd + + case "v", "space": + m.loadSelectedDetail() + return m, nil + + case "enter": + // Load detail if not loaded (to get the secret) + if m.selectedSecret == nil { + m.loadSelectedDetail() + } + // Copy Password + if m.selectedSecret != nil { + if err := clipboard.WriteAll(m.selectedSecret.Password); err != nil { + return m, m.flashMessage("Failed to copy password") + } + return m, m.flashMessage("Password Copied!") + } + + case "ctrl+t": + // Load detail if not loaded + if m.selectedSecret == nil { + m.loadSelectedDetail() + } + // Copy TOTP + if m.selectedSecret != nil && m.selectedSecret.TOTP != "" { + code, err := totp.GenerateCode(m.selectedSecret.TOTP, time.Now()) + if err != nil { + return m, m.flashMessage("Failed to generate TOTP: invalid secret") + } + if err := clipboard.WriteAll(code); err != nil { + return m, m.flashMessage("Failed to copy TOTP") + } + return m, m.flashMessage("TOTP Code Copied!") + } + + case "d": + if i, ok := m.list.SelectedItem().(item); ok { + if err := core.DeleteEntry(i.id); err != nil { + return m, m.flashMessage("Delete failed: " + err.Error()) + } + m.list.SetItems(loadItems()) + m.selectedSecret = nil + m.viewport.SetContent("") + return m, m.flashMessage("Deleted " + i.service) + } + } + + case stateAdd: + switch msg.String() { + case "esc": + m.state = stateVault + return m, nil + case "tab", "down", "enter": + if msg.String() == "enter" && m.focusedInput == len(m.inputs)-1 { + return m, m.submitAdd() + } + cmd = m.nextInput() + return m, cmd + case "shift+tab", "up": + cmd = m.prevInput() + return m, cmd + } + } + + case hideStatusMsg: + m.statusMessage = "" + } + + // Update Components based on state + switch m.state { + case stateInit, stateLogin: + m.authInput, cmd = m.authInput.Update(msg) + cmds = append(cmds, cmd) + case stateVault: + // Only update list if not already handled in KeyMsg special cases + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + case stateAdd: + cmd = m.updateInputs(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *Model) loadSelectedDetail() { + if i, ok := m.list.SelectedItem().(item); ok { + entry, err := storage.Find(i.service, i.username) + if err == nil { + secret, err := core.GetEntry(*entry) + if err == nil { + m.selectedSecret = secret + m.updateDetailView() + } + } + } +} + +func (m *Model) updateDetailView() { + if m.selectedSecret == nil { + m.viewport.SetContent("") + return + } + + ss := m.selectedSecret + var b strings.Builder + + b.WriteString(titleStyle.Render("DETAILS") + "\n\n") + + // Password + b.WriteString(labelStyle.Render("Password: ") + valueStyle.Render("••••••••") + " (Enter to copy)\n") + + // Env Key + if ss.EnvKey != "" { + b.WriteString(labelStyle.Render("Env Key: ") + valueStyle.Render(ss.EnvKey) + "\n") + } + + // TOTP + if ss.TOTP != "" { + code, err := totp.GenerateCode(ss.TOTP, time.Now()) + if err == nil { + b.WriteString(labelStyle.Render("2FA Code: ") + totpStyle.Render(code) + " (Ctrl+T to copy)\n") + } else { + b.WriteString(labelStyle.Render("2FA Code: ") + errorStyle.Render("Invalid Secret") + "\n") + } + } + + // Notes + if ss.Notes != "" { + b.WriteString("\n" + labelStyle.Render("Notes:") + "\n") + b.WriteString(valueStyle.Render(ss.Notes) + "\n") + } + + m.viewport.SetContent(detailStyle.Render(b.String())) +} + +func (m *Model) submitAdd() tea.Cmd { + service := m.inputs[0].Value() + username := m.inputs[1].Value() + pass := m.inputs[2].Value() + totpSecret := m.inputs[3].Value() + envKey := m.inputs[4].Value() + notes := m.inputs[5].Value() + + if service == "" || username == "" { + return nil + } + + if pass == "" { + var err error + pass, err = core.GenerateRandomPassword(24) + if err != nil { + return m.flashMessage("Failed to generate password: " + err.Error()) + } + } + + data := storage.SecretData{ + Password: pass, + TOTP: totpSecret, + EnvKey: envKey, + Notes: notes, + } + + if err := core.AddEntry(service, username, data); err != nil { + return m.flashMessage("Add failed: " + err.Error()) + } + m.list.SetItems(loadItems()) + m.state = stateVault + return m.flashMessage("Added " + service) +} + +// Helpers +func (m *Model) resetInputs() { + for i := range m.inputs { + m.inputs[i].SetValue("") + } + m.focusedInput = 0 + m.inputs[0].Focus() +} + +func (m *Model) nextInput() tea.Cmd { + m.focusedInput = (m.focusedInput + 1) % len(m.inputs) + return m.focusInput() +} + +func (m *Model) prevInput() tea.Cmd { + m.focusedInput-- + if m.focusedInput < 0 { + m.focusedInput = len(m.inputs) - 1 + } + return m.focusInput() +} + +func (m *Model) focusInput() tea.Cmd { + var cmds []tea.Cmd + for i := 0; i < len(m.inputs); i++ { + if i == m.focusedInput { + cmds = append(cmds, m.inputs[i].Focus()) + } else { + m.inputs[i].Blur() + } + } + return tea.Batch(cmds...) +} + +func (m *Model) updateInputs(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i := range m.inputs { + var cmd tea.Cmd + m.inputs[i], cmd = m.inputs[i].Update(msg) + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) +} + +func (m *Model) flashMessage(msg string) tea.Cmd { + m.statusMessage = statusMessageStyle.Render(msg) + return tea.Tick(time.Second*2, func(_ time.Time) tea.Msg { + return hideStatusMsg{} + }) +} + +type hideStatusMsg struct{} + +func (m Model) View() string { + if m.state == stateInit || m.state == stateLogin { + title := "UNLOCK VAULT" + subtitle := "Enter your master password to access your secrets." + if m.state == stateInit { + title = "WELCOME TO RAPG" + subtitle = "Create a master password to secure your vault.\n Requirements: Min 12 chars & strong complexity.\n This password is never stored and CANNOT be recovered." + } + + return appStyle.Render( + fmt.Sprintf("\n %s\n\n %s\n\n %s\n\n %s", + titleStyle.Render(title), + labelStyle.Render(subtitle), + m.authInput.View(), + errorStyle.Render(m.errorMessage), + ), + ) + } + + if m.state == stateAdd { + var b strings.Builder + b.WriteString(titleStyle.Render("ADD NEW ENTRY") + "\n\n") + for i, input := range m.inputs { + b.WriteString(input.View() + "\n") + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + b.WriteString("\n(esc to cancel, enter to submit)") + return appStyle.Render(b.String()) + } + + // Vault View (Split) + return appStyle.Render( + lipgloss.JoinHorizontal( + lipgloss.Top, + m.list.View(), + m.viewport.View(), + ) + "\n" + m.statusMessage, + ) +} diff --git a/pkg/rapg/api/api.go b/pkg/rapg/api/api.go deleted file mode 100644 index 72fe92a..0000000 --- a/pkg/rapg/api/api.go +++ /dev/null @@ -1,210 +0,0 @@ -package api - -import ( - "crypto/aes" - "crypto/rand" - "fmt" - "os" - "strings" - "unsafe" - - "github.com/jinzhu/gorm" - "github.com/kanywst/rapg/internal/crypto" - "github.com/kanywst/rapg/internal/out" -) - -type Record struct { - Url string - Username string - Password string -} - -var ( - commonIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f} -) - -var ( - homePath, _ = os.UserHomeDir() - dbPath = homePath + "/.rapg/pass.db" - keyPath = homePath + "/.rapg/.key_store" -) - -func MakeRandomPassword(digit int) (string, error) { - const letters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&()*+,-./:;<=>?@^_{|}~" - - b := make([]byte, digit) - if _, err := rand.Read(b); err != nil { - return "", err - } - - var result strings.Builder - for _, v := range b { - result.WriteByte(letters[int(v)%len(letters)]) - } - return result.String(), nil -} - -func CreateKey() { - f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE, 0666) - if err != nil { - panic(err) - } - defer f.Close() - - buf := make([]byte, 32) - n, err := f.Read(buf) - if err != nil { - panic(err) - } - readResult := buf[:n] - getKeyStore := *(*string)(unsafe.Pointer(&readResult)) - if getKeyStore == "" { - key, err := MakeRandomPassword(32) - if err != nil { - panic(err) - } - if _, err := f.Write([]byte(key)); err != nil { - panic(err) - } - out.Yellow("Created key.\nSaved at ~/.rapg/.key_store.") - } else { - out.Red("Already exists.") - } -} - -func ShowPassword(term string) { - db, err := gorm.Open("sqlite3", dbPath) - if err != nil { - panic("failed to connect database") - } - defer db.Close() - - var record Record - - key, err := readKeyFile() - if err != nil { - panic(err) - } - - c, err := aes.NewCipher(key) - if err != nil { - panic(err) - } - slice := strings.Split(term, "/") - if err := db.Find(&record, "url = ? AND username = ?", slice[0], slice[1]).Error; err != nil { - panic(err) - } - pass := []byte(record.Password) - - // Convert cipher.Block to []byte - ciphertext := make([]byte, len(pass)) - c.Encrypt(ciphertext, pass) - - decryptedPass, err := crypto.DecryptAES(ciphertext, key, commonIV) - if err != nil { - panic(err) - } - out.Green(string(decryptedPass)) -} - -func ShowList() { - db, err := gorm.Open("sqlite3", dbPath) - if err != nil { - fmt.Println("failed to connect database:", err) - return - } - defer db.Close() - - var records []Record - - if err := db.Find(&records).Error; err != nil { - fmt.Println("failed to retrieve records:", err) - return - } - - for _, data := range records { - out.Yellow(data.Url + "/" + data.Username) - } -} - -func AddPassword(term string, passlen int) { - db, err := gorm.Open("sqlite3", dbPath) - if err != nil { - fmt.Println("failed to connect database:", err) - return - } - defer db.Close() - - var record Record - slice := strings.Split(term, "/") - - url := slice[0] - username := slice[1] - - tableCheck := db.HasTable(&Record{}) - if tableCheck { - db.Find(&record, "url = ? AND username = ?", url, username) - } - if tableCheck && record.Url == url { - out.Red("Already url/username") - } else { - key, err := readKeyFile() - if err != nil { - panic(err) - } - - pass, err := MakeRandomPassword(passlen) - if err != nil { - fmt.Println("failed to generate password:", err) - return - } - out.Green(pass) - - encryptedPass, err := crypto.EncryptAES([]byte(pass), key, commonIV) - if err != nil { - panic(err) - } - _ = encryptedPass // Unused variable removed - - db.AutoMigrate(&Record{}) - if err := db.Create(&Record{Url: url, Username: username, Password: string(encryptedPass)}).Error; err != nil { - fmt.Println("failed to create record:", err) - return - } - } -} - -func RemovePassword(term string) { - db, err := gorm.Open("sqlite3", dbPath) - if err != nil { - fmt.Println("failed to connect database:", err) - return - } - defer db.Close() - - slice := strings.Split(term, "/") - if err := db.Where("url = ? AND username = ?", slice[0], slice[1]).Delete(&Record{}).Error; err != nil { - fmt.Println("failed to delete record:", err) - return - } -} - -func readKeyFile() ([]byte, error) { - f, err := os.OpenFile(keyPath, os.O_RDONLY, 0) - if err != nil { - if os.IsNotExist(err) { - return nil, err - } - return nil, err - } - defer f.Close() - - buf := make([]byte, 32) - n, err := f.Read(buf) - if err != nil { - panic(err) - } - key := buf[:n] - - return key, nil -}