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
+
+[](https://go.dev/)
+[](https://github.com/kanywst/rapg/actions)
+[](LICENSE)
+
+**Stop sharing `.env` files over Slack.**
+**Stop keeping cleartext credentials on your disk.**
+
+
+
+
+
+---
+
+## 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
-}