diff --git a/README.md b/README.md index 05e0f2b2..37f0878e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Pass-CLI is a fast, secure password and API key manager that stores credentials - **Usage Tracking**: Automatic tracking of where credentials are used across projects - **Offline First**: No cloud dependencies, works completely offline - **Interactive TUI**: Terminal UI for visual credential management +- **TOTP / 2FA Support**: Store TOTP secrets and generate 6-digit codes - no separate authenticator app needed ## Quick Start @@ -247,10 +248,6 @@ For more questions and troubleshooting, see [docs/04-troubleshooting/faq.md](doc ## Roadmap -Planned features for future releases: - -- **TOTP / 2FA Support**: Store TOTP secrets with credentials and generate 6-digit codes on demand - no separate authenticator app needed - Have a feature request? Open an issue on [GitHub](https://github.com/arimxyer/pass-cli/issues). ## Contributing diff --git a/cmd/add.go b/cmd/add.go index 36ebd01d..5b2c9761 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -14,13 +14,15 @@ import ( ) var ( - addUsername string - addPassword string - addCategory string - addURL string - addNotes string + addUsername string + addPassword string + addCategory string + addURL string + addNotes string addGeneratePassword bool - addGenLength int + addGenLength int + addTOTPURI string // TOTP otpauth:// URI + addTOTP bool // Prompt for TOTP secret interactively ) var addCmd = &cobra.Command{ @@ -38,6 +40,8 @@ hidden for security. If you want to provide these values via flags, use: --category (-c) for organizing credentials (e.g., 'Cloud', 'Databases') --url for the service URL (e.g., login page URL) --notes for additional information + --totp-uri to add TOTP/2FA support with an otpauth:// URI + --totp to be prompted for TOTP secret interactively The service name should be descriptive and unique (e.g., "github", "aws-prod", "db-staging").`, Example: ` # Add a credential with prompts @@ -59,7 +63,13 @@ The service name should be descriptive and unique (e.g., "github", "aws-prod", " pass-cli add github -u user@example.com -g --gen-length 32 # Add with all metadata fields - pass-cli add github -u user@example.com -c "Version Control" --url "https://github.com" --notes "Work account"`, + pass-cli add github -u user@example.com -c "Version Control" --url "https://github.com" --notes "Work account" + + # Add with TOTP/2FA support + pass-cli add github -u user@example.com --totp-uri "otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP&issuer=GitHub" + + # Add with interactive TOTP prompt + pass-cli add github -u user@example.com --totp`, Args: cobra.ExactArgs(1), RunE: runAdd, } @@ -73,9 +83,13 @@ func init() { addCmd.Flags().StringVarP(&addCategory, "category", "c", "", "category for organizing credentials (e.g., 'Cloud', 'Databases')") addCmd.Flags().StringVar(&addURL, "url", "", "URL associated with the credential (e.g., login page)") addCmd.Flags().StringVar(&addNotes, "notes", "", "optional notes about the credential") + addCmd.Flags().StringVar(&addTOTPURI, "totp-uri", "", "TOTP/2FA otpauth:// URI (from QR code or authenticator app)") + addCmd.Flags().BoolVar(&addTOTP, "totp", false, "prompt for TOTP secret interactively") // Mark --password and --generate as mutually exclusive addCmd.MarkFlagsMutuallyExclusive("password", "generate") + // Mark --totp-uri and --totp as mutually exclusive + addCmd.MarkFlagsMutuallyExclusive("totp-uri", "totp") } func runAdd(cmd *cobra.Command, args []string) error { @@ -156,6 +170,48 @@ func runAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to add credential: %w", err) } + // Handle TOTP if provided + var totpConfigured bool + if addTOTPURI != "" || addTOTP { + totpURI := addTOTPURI + + // If --totp flag is set, prompt for TOTP secret + if addTOTP { + fmt.Print("TOTP Secret (base32) or otpauth:// URI: ") + var totpInput string + if _, err := fmt.Scanln(&totpInput); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Warning: failed to read TOTP input, skipping TOTP setup: %v\n", err) + } else { + totpURI = strings.TrimSpace(totpInput) + } + } + + if totpURI != "" { + // Parse and validate TOTP + totpConfig, err := vault.ParseTOTPURI(totpURI) + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Warning: invalid TOTP configuration: %v\n", err) + } else { + // Update credential with TOTP fields + opts := vault.UpdateOpts{ + TOTPSecret: &totpConfig.Secret, + TOTPAlgorithm: &totpConfig.Algorithm, + TOTPDigits: &totpConfig.Digits, + TOTPPeriod: &totpConfig.Period, + } + if totpConfig.Issuer != "" { + opts.TOTPIssuer = &totpConfig.Issuer + } + + if err := vaultService.UpdateCredential(service, opts); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Warning: failed to save TOTP configuration: %v\n", err) + } else { + totpConfigured = true + } + } + } + } + // Success message fmt.Printf("✅ Credential added successfully!\n") fmt.Printf("📝 Service: %s\n", service) @@ -171,6 +227,9 @@ func runAdd(cmd *cobra.Command, args []string) error { if addNotes != "" { fmt.Printf("📋 Notes: %s\n", addNotes) } + if totpConfigured { + fmt.Printf("🔐 TOTP: configured\n") + } return nil } diff --git a/cmd/get.go b/cmd/get.go index b37adf67..54b46f4d 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -17,6 +17,9 @@ var ( getField string getNoClipboard bool getMasked bool + getTOTP bool // Output TOTP code instead of password + getTOTPQR bool // Display TOTP QR code in terminal + getTOTPQRFile string // Export TOTP QR code to file ) var getCmd = &cobra.Command{ @@ -32,6 +35,9 @@ are displayed. Use flags to customize the output: --field Extract a specific field (username, password, category, url, notes, service) --no-clipboard Skip copying to clipboard --masked Display password as asterisks (default shows full password) + --totp Output TOTP code instead of password (requires TOTP to be configured) + --totp-qr Display TOTP QR code in terminal (for adding to another device) + --totp-qr-file Export TOTP QR code to a PNG file Automatic usage tracking records where credentials are accessed based on your current working directory.`, @@ -48,7 +54,19 @@ your current working directory.`, pass-cli get github --no-clipboard # Get with masked password display - pass-cli get github --masked`, + pass-cli get github --masked + + # Get TOTP code + pass-cli get github --totp + + # Get TOTP code for scripts + pass-cli get github --totp --quiet + + # Display TOTP QR code in terminal (to add to another device) + pass-cli get github --totp-qr + + # Export TOTP QR code to a PNG file + pass-cli get github --totp-qr-file totp-github.png`, Args: cobra.ExactArgs(1), RunE: runGet, } @@ -59,6 +77,9 @@ func init() { getCmd.Flags().StringVarP(&getField, "field", "f", "password", "field to extract (username, password, category, url, notes, service)") getCmd.Flags().BoolVar(&getNoClipboard, "no-clipboard", false, "do not copy to clipboard") getCmd.Flags().BoolVar(&getMasked, "masked", false, "display password as asterisks") + getCmd.Flags().BoolVar(&getTOTP, "totp", false, "output TOTP code instead of password") + getCmd.Flags().BoolVar(&getTOTPQR, "totp-qr", false, "display TOTP QR code in terminal") + getCmd.Flags().StringVar(&getTOTPQRFile, "totp-qr-file", "", "export TOTP QR code to PNG file") } func runGet(cmd *cobra.Command, args []string) error { @@ -92,6 +113,21 @@ func runGet(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get credential: %w", err) } + // TOTP QR code display mode + if getTOTPQR { + return outputTOTPQRMode(cred, service) + } + + // TOTP QR code file export mode + if getTOTPQRFile != "" { + return exportTOTPQRFile(cred, service, getTOTPQRFile) + } + + // TOTP mode - output TOTP code + if getTOTP { + return outputTOTPMode(cred, vaultService, service) + } + // Quiet mode - output only requested field if getQuiet { return outputQuietMode(cred, vaultService, service) @@ -139,6 +175,129 @@ func outputQuietMode(cred *vault.Credential, vaultService *vault.VaultService, s return nil } +// outputTOTPMode generates and displays the TOTP code +func outputTOTPMode(cred *vault.Credential, vaultService *vault.VaultService, service string) error { + // Check time sync in background (don't block code generation) + timeSyncChan := make(chan vault.TimeSyncResult, 1) + go func() { + timeSyncChan <- vault.CheckTimeSync() + }() + + // Generate TOTP code with audit logging + code, remaining, err := vaultService.GetTOTPCode(service) + if err != nil { + return fmt.Errorf("failed to generate TOTP code: %w", err) + } + + // Track TOTP access for usage statistics + if err := vaultService.RecordFieldAccess(service, "totp"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to track TOTP access: %v\n", err) + } + + // Quiet mode - just output the code + if getQuiet { + fmt.Println(code) + return nil + } + + // Check for time sync warning (non-blocking, with short timeout) + select { + case result := <-timeSyncChan: + if warning := vault.FormatTimeSyncWarning(result); warning != "" { + fmt.Fprintln(os.Stderr, warning) + fmt.Fprintln(os.Stderr) + } + case <-time.After(100 * time.Millisecond): + // Don't wait too long - time check is best-effort + } + + // Normal mode - show code with countdown + fmt.Printf("🔐 TOTP Code: %s\n", code) + fmt.Printf("⏱ Valid for: %ds\n", remaining) + + // Show progress bar + period := 30 + if cred.TOTPPeriod > 0 { + period = cred.TOTPPeriod + } + progress := float64(remaining) / float64(period) + barWidth := 20 + filled := int(progress * float64(barWidth)) + empty := barWidth - filled + fmt.Printf(" [%s%s]\n", strings.Repeat("█", filled), strings.Repeat("░", empty)) + + // Copy to clipboard unless disabled + if !getNoClipboard { + if err := clipboard.WriteAll(code); err != nil { + fmt.Fprintf(os.Stderr, "\n⚠️ Warning: failed to copy to clipboard: %v\n", err) + } else { + fmt.Println("\n✅ TOTP code copied to clipboard!") + + // Schedule clipboard clear in background (based on remaining validity) + go func() { + time.Sleep(time.Duration(remaining) * time.Second) + // Only clear if the clipboard still contains our code + if current, err := clipboard.ReadAll(); err == nil && current == code { + _ = clipboard.WriteAll("") + if IsVerbose() { + fmt.Fprintln(os.Stderr, "🧹 Clipboard cleared") + } + } + }() + } + } + + return nil +} + +// outputTOTPQRMode displays the TOTP QR code in the terminal +func outputTOTPQRMode(cred *vault.Credential, service string) error { + if !cred.HasTOTP() { + return fmt.Errorf("no TOTP configured for credential: %s", service) + } + + fmt.Printf("🔐 TOTP QR Code for: %s\n", service) + if cred.TOTPIssuer != "" { + fmt.Printf(" Issuer: %s\n", cred.TOTPIssuer) + } + fmt.Println() + fmt.Println("Scan this QR code with your authenticator app:") + fmt.Println() + + if err := cred.DisplayQRCode(os.Stdout); err != nil { + return fmt.Errorf("failed to display QR code: %w", err) + } + + fmt.Println() + fmt.Println("⚠️ Keep this QR code private - it contains your TOTP secret!") + + return nil +} + +// exportTOTPQRFile exports the TOTP QR code to a PNG file +func exportTOTPQRFile(cred *vault.Credential, service string, filename string) error { + if !cred.HasTOTP() { + return fmt.Errorf("no TOTP configured for credential: %s", service) + } + + // Default size of 256x256 pixels + size := 256 + + if err := cred.ExportQRCode(filename, size); err != nil { + return fmt.Errorf("failed to export QR code: %w", err) + } + + fmt.Printf("✅ TOTP QR code exported to: %s\n", filename) + fmt.Printf(" Service: %s\n", service) + if cred.TOTPIssuer != "" { + fmt.Printf(" Issuer: %s\n", cred.TOTPIssuer) + } + fmt.Println() + fmt.Println("⚠️ Keep this file private - it contains your TOTP secret!") + + return nil +} + func outputNormalMode(cred *vault.Credential, vaultService *vault.VaultService, service string) error { // Display credential details fmt.Printf("📝 Service: %s\n", cred.Service) @@ -167,6 +326,15 @@ func outputNormalMode(cred *vault.Credential, vaultService *vault.VaultService, fmt.Printf("📋 Notes: %s\n", cred.Notes) } + // Display TOTP status if configured + if cred.HasTOTP() { + issuer := cred.TOTPIssuer + if issuer == "" { + issuer = "configured" + } + fmt.Printf("🔐 TOTP: %s (use --totp to get code)\n", issuer) + } + // Display timestamps fmt.Printf("📅 Created: %s\n", cred.CreatedAt.Format("2006-01-02 15:04:05")) if !cred.UpdatedAt.Equal(cred.CreatedAt) { diff --git a/cmd/tui/components/detail.go b/cmd/tui/components/detail.go index 63c4fca0..7abbc3c5 100644 --- a/cmd/tui/components/detail.go +++ b/cmd/tui/components/detail.go @@ -115,6 +115,17 @@ func (dv *DetailView) formatCredential(cred *vault.CredentialMetadata) string { // Password field with masking dv.formatPasswordField(&b, cred) + // TOTP field (if configured) + if cred.HasTOTP { + issuerDisplay := cred.TOTPIssuer + if issuerDisplay == "" { + issuerDisplay = "configured" + } + b.WriteString(fmt.Sprintf("%sTOTP:%s %s %s(Press 't' to copy code)%s\n", + colorWithBg("lightSlateGray"), textColor(), issuerDisplay, + colorWithBg("lightSlateGray"), textColor())) + } + // Notes (if present) if cred.Notes != "" { b.WriteString(fmt.Sprintf("\n%sNotes:%s\n", colorWithBg("lightSlateGray"), textColor())) @@ -316,6 +327,37 @@ func (dv *DetailView) CopyFieldToClipboard(field string) error { return nil } +// CopyTOTPToClipboard generates and copies the TOTP code to clipboard. +// Returns the remaining seconds until the code expires, or error if no TOTP configured. +func (dv *DetailView) CopyTOTPToClipboard() (int, error) { + cred := dv.appState.GetSelectedCredential() + if cred == nil { + return 0, fmt.Errorf("no credential selected") + } + + if !cred.HasTOTP { + return 0, fmt.Errorf("no TOTP configured for %s", cred.Service) + } + + // Get TOTP code from vault service (includes audit logging) + code, remaining, err := dv.appState.GetTOTPCode(cred.Service) + if err != nil { + return 0, fmt.Errorf("failed to generate TOTP code: %w", err) + } + + // Copy to clipboard + if err := clipboard.WriteAll(code); err != nil { + return 0, fmt.Errorf("failed to copy to clipboard: %w", err) + } + + // Track TOTP access + if err := dv.appState.RecordFieldAccess(cred.Service, "totp"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to track TOTP access: %v\n", err) + } + + return remaining, nil +} + // applyStyles applies theme colors and borders to the detail view. // Uses theme system for consistent styling. func (dv *DetailView) applyStyles() { diff --git a/cmd/tui/components/detail_test.go b/cmd/tui/components/detail_test.go index 3d00fc32..572dc0a9 100644 --- a/cmd/tui/components/detail_test.go +++ b/cmd/tui/components/detail_test.go @@ -72,6 +72,10 @@ func (t *testVaultService) RecordFieldAccess(service, field string) error { return nil } +func (t *testVaultService) GetTOTPCode(service string) (string, int, error) { + return "", 0, fmt.Errorf("TOTP not configured") +} + // Test helper functions // CreateTestCredential creates a test credential with usage records diff --git a/cmd/tui/components/forms.go b/cmd/tui/components/forms.go index 8ad5c22c..14ccfce5 100644 --- a/cmd/tui/components/forms.go +++ b/cmd/tui/components/forms.go @@ -53,6 +53,7 @@ type EditForm struct { originalPassword string // Track original password to detect changes passwordFetched bool // Track if password has been fetched (lazy loading) passwordVisible bool // Track password visibility state for toggle + clearTOTP bool // Track if user wants to clear TOTP onSubmit func() onCancel func() @@ -141,6 +142,9 @@ func (af *AddForm) buildFormFields() { af.form.AddInputField("URL", "", 0, nil, nil) af.form.AddTextArea("Notes", "", 0, 5, 0, nil) + // TOTP field (optional) - accepts base32 secret or otpauth:// URI + af.form.AddInputField("TOTP Secret/URI", "", 0, nil, nil) + // Action buttons af.form.AddButton("Generate Password", af.onGeneratePassword) af.form.AddButton("Add", af.onAddPressed) @@ -168,6 +172,7 @@ func (af *AddForm) onAddPressed() { url := af.form.GetFormItem(4).(*tview.InputField).GetText() notes := af.form.GetFormItem(5).(*tview.TextArea).GetText() + totpInput := af.form.GetFormItem(6).(*tview.InputField).GetText() // Call AppState to add credential with all 6 fields err := af.appState.AddCredential(service, username, password, category, url, notes) @@ -177,6 +182,29 @@ func (af *AddForm) onAddPressed() { return } + // If TOTP was provided, update credential with TOTP fields + if totpInput != "" { + totpConfig, err := vault.ParseTOTPURI(strings.TrimSpace(totpInput)) + if err != nil { + // TOTP parsing failed - credential was added but without TOTP + // Could show warning but don't fail the whole operation + // Form will close, user can edit later to fix TOTP + } else { + // Update credential with TOTP fields + opts := models.UpdateCredentialOpts{ + TOTPSecret: &totpConfig.Secret, + TOTPAlgorithm: &totpConfig.Algorithm, + TOTPDigits: &totpConfig.Digits, + TOTPPeriod: &totpConfig.Period, + } + if totpConfig.Issuer != "" { + opts.TOTPIssuer = &totpConfig.Issuer + } + // Ignore error - credential was added, TOTP is optional + _ = af.appState.UpdateCredential(service, opts) + } + } + // Success - invoke callback to close modal if af.onSubmit != nil { af.onSubmit() @@ -237,12 +265,13 @@ func (af *AddForm) hasUnsavedData() bool { category := af.form.GetFormItem(3).(*tview.InputField).GetText() url := af.form.GetFormItem(4).(*tview.InputField).GetText() notes := af.form.GetFormItem(5).(*tview.TextArea).GetText() + totp := af.form.GetFormItem(6).(*tview.InputField).GetText() // Consider form "dirty" if any field has non-empty value // Ignore "Uncategorized" since it's the default return service != "" || username != "" || password != "" || (category != "" && category != "Uncategorized") || - url != "" || notes != "" + url != "" || notes != "" || totp != "" } // validate checks that required fields are filled. @@ -373,9 +402,9 @@ func (af *AddForm) applyStyles() { // Apply form-level styling styles.ApplyFormStyle(af.form) - // Style individual input fields + // Style individual input fields (7 fields: Service, Username, Password, Category, URL, Notes, TOTP) // Use BackgroundLight for input fields - lighter than form Background for contrast - for i := 0; i < 6; i++ { + for i := 0; i < 7; i++ { item := af.form.GetFormItem(i) switch field := item.(type) { case *tview.InputField: @@ -528,6 +557,24 @@ func (ef *EditForm) buildFormFieldsWithValues() { ef.form.AddInputField("URL", ef.credential.URL, 0, nil, nil) ef.form.AddTextArea("Notes", ef.credential.Notes, 0, 5, 0, nil) + // TOTP field - show current status in label, allow adding/updating + totpLabel := "TOTP Secret/URI" + if ef.credential.HasTOTP { + if ef.credential.TOTPIssuer != "" { + totpLabel = fmt.Sprintf("TOTP [%s] (leave empty to keep)", ef.credential.TOTPIssuer) + } else { + totpLabel = "TOTP [configured] (leave empty to keep)" + } + } + ef.form.AddInputField(totpLabel, "", 0, nil, nil) + + // Clear TOTP checkbox - only meaningful if credential has TOTP + if ef.credential.HasTOTP { + ef.form.AddCheckbox("Clear TOTP", false, func(checked bool) { + ef.clearTOTP = checked + }) + } + // Action buttons ef.form.AddButton("Generate Password", ef.onGeneratePassword) ef.form.AddButton("Save", ef.onSavePressed) @@ -605,6 +652,7 @@ func (ef *EditForm) performSave() { url := ef.form.GetFormItem(4).(*tview.InputField).GetText() notes := ef.form.GetFormItem(5).(*tview.TextArea).GetText() + totpInput := ef.form.GetFormItem(6).(*tview.InputField).GetText() // Build UpdateCredentialOpts with only non-empty fields opts := models.UpdateCredentialOpts{} @@ -630,6 +678,26 @@ func (ef *EditForm) performSave() { // Always set notes (even if empty, to allow clearing) opts.Notes = ¬es + // Handle TOTP: clear takes precedence, then update if provided + if ef.clearTOTP { + opts.ClearTOTP = true + } else if totpInput != "" { + // Parse and validate TOTP input + totpConfig, err := vault.ParseTOTPURI(strings.TrimSpace(totpInput)) + if err != nil { + // TOTP parsing failed - don't fail the whole save, just skip TOTP update + // User can try again + } else { + opts.TOTPSecret = &totpConfig.Secret + opts.TOTPAlgorithm = &totpConfig.Algorithm + opts.TOTPDigits = &totpConfig.Digits + opts.TOTPPeriod = &totpConfig.Period + if totpConfig.Issuer != "" { + opts.TOTPIssuer = &totpConfig.Issuer + } + } + } + // Call AppState to update credential with options struct err := ef.appState.UpdateCredential(service, opts) if err != nil { @@ -697,6 +765,7 @@ func (ef *EditForm) hasUnsavedChanges() bool { category := ef.form.GetFormItem(3).(*tview.InputField).GetText() url := ef.form.GetFormItem(4).(*tview.InputField).GetText() notes := ef.form.GetFormItem(5).(*tview.TextArea).GetText() + totpInput := ef.form.GetFormItem(6).(*tview.InputField).GetText() // Normalize current category for comparison normalizedCategory := normalizeCategory(category) @@ -706,7 +775,9 @@ func (ef *EditForm) hasUnsavedChanges() bool { password != ef.originalPassword || normalizedCategory != ef.credential.Category || url != ef.credential.URL || - notes != ef.credential.Notes + notes != ef.credential.Notes || + totpInput != "" || // Any TOTP input means changes + ef.clearTOTP // Clear TOTP checkbox is checked } // validate checks that required fields are filled. @@ -834,8 +905,14 @@ func (ef *EditForm) applyStyles() { styles.ApplyFormStyle(ef.form) // Style individual input fields + // Form has 7 fields (Service, Username, Password, Category, URL, Notes, TOTP) + // Plus optional Clear TOTP checkbox (8th item) if credential has TOTP // Use BackgroundLight for input fields - lighter than form Background for contrast - for i := 0; i < 6; i++ { + numFields := 7 + if ef.credential.HasTOTP { + numFields = 8 // Include Clear TOTP checkbox + } + for i := 0; i < numFields; i++ { item := ef.form.GetFormItem(i) switch field := item.(type) { case *tview.InputField: @@ -848,6 +925,9 @@ func (ef *EditForm) applyStyles() { case *tview.DropDown: field.SetFieldBackgroundColor(theme.BackgroundLight). SetFieldTextColor(theme.TextPrimary) + case *tview.Checkbox: + field.SetFieldBackgroundColor(theme.BackgroundLight). + SetFieldTextColor(theme.TextPrimary) } } diff --git a/cmd/tui/components/forms_password_toggle_test.go b/cmd/tui/components/forms_password_toggle_test.go index dcf7a0f3..84e0b449 100644 --- a/cmd/tui/components/forms_password_toggle_test.go +++ b/cmd/tui/components/forms_password_toggle_test.go @@ -91,6 +91,10 @@ func (m *mockVaultServiceForForms) RecordFieldAccess(service, field string) erro return nil } +func (m *mockVaultServiceForForms) GetTOTPCode(service string) (string, int, error) { + return "", 0, errors.New("TOTP not configured") +} + // TestAddFormPasswordVisibilityToggle verifies the toggle changes label // T004: Unit test for AddForm password visibility toggle functionality // NOTE: tview InputField doesn't expose GetMaskCharacter(), so we test via label changes diff --git a/cmd/tui/components/sidebar_test.go b/cmd/tui/components/sidebar_test.go index 7d7ec02e..2a0108bd 100644 --- a/cmd/tui/components/sidebar_test.go +++ b/cmd/tui/components/sidebar_test.go @@ -90,6 +90,10 @@ func (m *MockVaultService) RecordFieldAccess(service, field string) error { return nil } +func (m *MockVaultService) GetTOTPCode(service string) (string, int, error) { + return "", 0, errors.New("TOTP not configured") +} + func (m *MockVaultService) SetCredentials(creds []vault.CredentialMetadata) { m.mu.Lock() defer m.mu.Unlock() diff --git a/cmd/tui/events/handlers.go b/cmd/tui/events/handlers.go index c74c3f16..2a6eb22c 100644 --- a/cmd/tui/events/handlers.go +++ b/cmd/tui/events/handlers.go @@ -177,6 +177,9 @@ func (eh *EventHandler) handleGlobalKey(event *tcell.EventKey) *tcell.EventKey { case 'n': eh.handleCopyField("notes") return nil + case 't': + eh.handleCopyTOTP() + return nil } } @@ -311,6 +314,20 @@ func (eh *EventHandler) handleCopyField(field string) { } } +// handleCopyTOTP generates and copies the TOTP code to clipboard. +func (eh *EventHandler) handleCopyTOTP() { + if eh.detailView == nil { + return + } + + remaining, err := eh.detailView.CopyTOTPToClipboard() + if err != nil { + eh.statusBar.ShowError(err) + } else { + eh.statusBar.ShowSuccess(fmt.Sprintf("TOTP code copied! Valid for %ds", remaining)) + } +} + // handleToggleDetailPanel toggles the detail panel visibility through three states. // Cycles: Auto (responsive) -> Hide -> Show -> Auto // Displays status bar message showing the new state. @@ -412,6 +429,7 @@ func (eh *EventHandler) handleShowHelp() { addShortcut("u", "Copy username") addShortcut("l", "Copy URL") addShortcut("n", "Copy notes") + addShortcut("t", "Copy TOTP code") row++ // Blank line (just skip row, don't add cells) // View section diff --git a/cmd/tui/models/state.go b/cmd/tui/models/state.go index 245617ca..0de85ec2 100644 --- a/cmd/tui/models/state.go +++ b/cmd/tui/models/state.go @@ -22,7 +22,8 @@ type VaultService interface { UpdateCredential(service string, opts vault.UpdateOpts) error DeleteCredential(service string) error GetCredential(service string, trackUsage bool) (*vault.Credential, error) - RecordFieldAccess(service, field string) error // Track field-specific access + RecordFieldAccess(service, field string) error // Track field-specific access + GetTOTPCode(service string) (string, int, error) // Generate TOTP code with remaining seconds } // UpdateCredentialOpts mirrors vault.UpdateOpts for AppState layer. @@ -34,6 +35,14 @@ type UpdateCredentialOpts struct { Category *string URL *string Notes *string + + // TOTP fields (nil = don't change, non-nil = set value) + TOTPSecret *string + TOTPAlgorithm *string + TOTPDigits *int + TOTPPeriod *int + TOTPIssuer *string + ClearTOTP bool // If true, clears all TOTP fields } // AppState holds all application state with thread-safe access. @@ -159,6 +168,15 @@ func (s *AppState) RecordFieldAccess(service, field string) error { return s.vault.RecordFieldAccess(service, field) } +// GetTOTPCode generates a TOTP code for the specified service. +// Returns the code, remaining seconds until expiration, and any error. +func (s *AppState) GetTOTPCode(service string) (string, int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.vault.GetTOTPCode(service) +} + // LoadCredentials loads all credentials from the vault. // CRITICAL: Follows Lock→Mutate→Unlock→Notify pattern to prevent deadlocks. func (s *AppState) LoadCredentials() error { @@ -224,11 +242,17 @@ func (s *AppState) AddCredential(service, username, password, category, url, not func (s *AppState) UpdateCredential(service string, opts UpdateCredentialOpts) error { // Convert AppState UpdateCredentialOpts to vault.UpdateOpts vaultOpts := vault.UpdateOpts{ - Username: opts.Username, - Password: opts.Password, - Category: opts.Category, - URL: opts.URL, - Notes: opts.Notes, + Username: opts.Username, + Password: opts.Password, + Category: opts.Category, + URL: opts.URL, + Notes: opts.Notes, + TOTPSecret: opts.TOTPSecret, + TOTPAlgorithm: opts.TOTPAlgorithm, + TOTPDigits: opts.TOTPDigits, + TOTPPeriod: opts.TOTPPeriod, + TOTPIssuer: opts.TOTPIssuer, + ClearTOTP: opts.ClearTOTP, } // Perform vault I/O without holding lock (vault has its own synchronization) diff --git a/cmd/tui/models/state_test.go b/cmd/tui/models/state_test.go index 9be381bd..decb3208 100644 --- a/cmd/tui/models/state_test.go +++ b/cmd/tui/models/state_test.go @@ -163,6 +163,10 @@ func (m *MockVaultService) RecordFieldAccess(service, field string) error { return nil } +func (m *MockVaultService) GetTOTPCode(service string) (string, int, error) { + return "", 0, errors.New("TOTP not configured") +} + // SetCredentials sets the mock credentials for testing. func (m *MockVaultService) SetCredentials(creds []vault.CredentialMetadata) { m.mu.Lock() diff --git a/cmd/update.go b/cmd/update.go index 52ff0824..26470e56 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -26,6 +26,8 @@ var ( clearNotes bool updateGeneratePassword bool updateGenLength int + updateTOTPURI string // TOTP otpauth:// URI + clearTOTP bool // Clear TOTP configuration ) var updateCmd = &cobra.Command{ @@ -37,12 +39,15 @@ var updateCmd = &cobra.Command{ You can selectively update individual fields (username, password, category, url, notes) without affecting the others. Empty values mean "don't change". -To explicitly clear optional fields (category, url, notes) to empty, use the --clear-* flags. +To explicitly clear optional fields (category, url, notes, totp) to empty, use the --clear-* flags. These flags take precedence over corresponding value flags. Use --generate to auto-generate a new secure password (password rotation). The generated password will be copied to clipboard automatically. +Use --totp-uri to add or update TOTP/2FA configuration for the credential. +Use --clear-totp to remove TOTP configuration. + By default, you'll see a usage warning if the credential has been accessed before, showing where and when it was last used. Use --force to skip the confirmation.`, Example: ` # Update password only (interactive prompt) @@ -81,6 +86,12 @@ showing where and when it was last used. Use --force to skip the confirmation.`, # Generate new 32-character password pass-cli update github -g --gen-length 32 + # Add or update TOTP/2FA + pass-cli update github --totp-uri "otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP&issuer=GitHub" + + # Remove TOTP/2FA configuration + pass-cli update github --clear-totp + # Skip confirmation pass-cli update github --force`, Args: cobra.ExactArgs(1), @@ -99,10 +110,14 @@ func init() { updateCmd.Flags().BoolVar(&clearCategory, "clear-category", false, "clear category field to empty") updateCmd.Flags().BoolVar(&clearURL, "clear-url", false, "clear URL field to empty") updateCmd.Flags().BoolVar(&clearNotes, "clear-notes", false, "clear notes field to empty") + updateCmd.Flags().StringVar(&updateTOTPURI, "totp-uri", "", "TOTP/2FA otpauth:// URI to add or update") + updateCmd.Flags().BoolVar(&clearTOTP, "clear-totp", false, "remove TOTP/2FA configuration") updateCmd.Flags().BoolVar(&updateForce, "force", false, "skip confirmation prompt") // Mark --password and --generate as mutually exclusive updateCmd.MarkFlagsMutuallyExclusive("password", "generate") + // Mark --totp-uri and --clear-totp as mutually exclusive + updateCmd.MarkFlagsMutuallyExclusive("totp-uri", "clear-totp") } func runUpdate(cmd *cobra.Command, args []string) error { @@ -154,7 +169,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // If no flags provided (including clear flags), prompt for what to update if updateUsername == "" && updatePassword == "" && updateNotes == "" && updateCategory == "" && updateURL == "" && - !clearCategory && !clearURL && !clearNotes && !updateGeneratePassword { + updateTOTPURI == "" && !clearCategory && !clearURL && !clearNotes && !clearTOTP && !updateGeneratePassword { fmt.Println("What would you like to update? (leave empty to keep current value)") fmt.Println() @@ -204,7 +219,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Check if anything is being updated if updateUsername == "" && updatePassword == "" && updateNotes == "" && updateCategory == "" && updateURL == "" && - !clearCategory && !clearURL && !clearNotes && !updateGeneratePassword { + updateTOTPURI == "" && !clearCategory && !clearURL && !clearNotes && !clearTOTP && !updateGeneratePassword { fmt.Println("No changes specified.") return nil } @@ -273,6 +288,24 @@ func runUpdate(cmd *cobra.Command, args []string) error { opts.URL = &updateURL } + // Handle TOTP: clear flag takes precedence + if clearTOTP { + opts.ClearTOTP = true + } else if updateTOTPURI != "" { + // Parse and validate TOTP URI + totpConfig, err := vault.ParseTOTPURI(updateTOTPURI) + if err != nil { + return fmt.Errorf("invalid TOTP URI: %w", err) + } + opts.TOTPSecret = &totpConfig.Secret + opts.TOTPAlgorithm = &totpConfig.Algorithm + opts.TOTPDigits = &totpConfig.Digits + opts.TOTPPeriod = &totpConfig.Period + if totpConfig.Issuer != "" { + opts.TOTPIssuer = &totpConfig.Issuer + } + } + if err := vaultService.UpdateCredential(service, opts); err != nil { return fmt.Errorf("failed to update credential: %w", err) } @@ -302,6 +335,11 @@ func runUpdate(cmd *cobra.Command, args []string) error { } else if updateNotes != "" { fmt.Printf("📋 New notes: %s\n", updateNotes) } + if clearTOTP { + fmt.Printf("🔐 TOTP cleared\n") + } else if updateTOTPURI != "" { + fmt.Printf("🔐 TOTP configured\n") + } return nil } diff --git a/docs/02-guides/_index.md b/docs/02-guides/_index.md index 051b2aa4..0ecfd6ca 100644 --- a/docs/02-guides/_index.md +++ b/docs/02-guides/_index.md @@ -12,5 +12,6 @@ Task-oriented guides for common pass-cli workflows and features. {{< card link="usage-tracking" title="Usage Tracking" icon="chart-bar" subtitle="Automatic credential usage tracking by working directory" >}} {{< card link="backup-restore" title="Backup & Restore" icon="document-duplicate" subtitle="Vault backup and disaster recovery procedures" >}} {{< card link="tui-guide" title="TUI Guide" icon="terminal" subtitle="Interactive terminal user interface guide" >}} + {{< card link="totp-guide" title="TOTP & 2FA" icon="shield-check" subtitle="Store TOTP secrets and generate 2FA codes" >}} {{< card link="scripting-guide" title="Scripting Guide" icon="code" subtitle="Automate pass-cli with scripts and CI/CD integration" >}} {{< /cards >}} diff --git a/docs/02-guides/totp-guide.md b/docs/02-guides/totp-guide.md new file mode 100644 index 00000000..abf4059d --- /dev/null +++ b/docs/02-guides/totp-guide.md @@ -0,0 +1,111 @@ +--- +title: "TOTP & 2FA Guide" +weight: 7 +toc: true +--- + +# TOTP & 2FA Support + +Pass-CLI supports storing TOTP (Time-based One-Time Password) secrets and generating 6-digit or 8-digit codes directly from your terminal. This eliminates the need for a separate authenticator app on your phone for many services. + +## Adding TOTP to a Credential + +You can add TOTP support to a new or existing credential in three ways: + +### 1. Interactive Prompt (Recommended) + +When adding or updating a credential, use the `--totp` flag to be prompted for the secret: + +```bash +# Add new credential with TOTP +pass-cli add github --totp + +# Add TOTP to existing credential +pass-cli update github --totp +``` + +### 2. Using an otpauth:// URI + +If you have the full `otpauth://` URI (often provided by services for manual entry), you can provide it directly: + +```bash +pass-cli add github --totp-uri "otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP&issuer=GitHub" +``` + +### 3. In the TUI + +1. Launch `pass-cli` (TUI mode) +2. Select a credential and press `e` to edit +3. Navigate to the TOTP field and enter your secret or URI +4. Save the changes + +## Generating TOTP Codes + +Once configured, you can generate codes using the `get` command: + +```bash +# Display credential info including current TOTP code +pass-cli get github + +# Display ONLY the TOTP code (useful for scripts) +pass-cli get github --totp --quiet + +# Copy TOTP code to clipboard in TUI +# Select credential and press 't' +``` + +## QR Code Support + +Pass-CLI can display and export QR codes, making it easy to sync credentials with other authenticator apps (like Authy or Google Authenticator). + +### Display QR Code in Terminal + +```bash +pass-cli get github --totp-qr +``` + +### Export QR Code to File + +```bash +pass-cli get github --totp-qr-file github-qr.png +``` + +> **Warning**: Keep QR code files secure as they contain your TOTP secret in plain text. + +## How Service and Username are Used + +When generating a TOTP URI or QR code, Pass-CLI uses your credential's fields to build the label that appears in your authenticator app. + +### Field Mapping + +The following logic is used to determine what appears in your authenticator app: + +1. **Issuer (Company Name)**: + - Uses `TOTPIssuer` if set. + - Falls back to `Service` if `TOTPIssuer` is empty. + - Example: "GitHub", "Google", "AWS" + +2. **Account Identifier**: + - Uses `Username` if set. + - Falls back to `Service` if `Username` is empty. + - Example: "user@example.com", "admin" + +### Fallback Behavior + +- If `TOTPIssuer` is empty, the **Service** name is used as the issuer. +- If `Username` is empty, the **Service** name is used as the account name. +- If **both** Service and Username are empty, Pass-CLI will return an error when trying to generate a QR code, as it cannot build a valid URI. + +### Best Practices + +- **Set Username**: Always set the `Username` field if you have multiple accounts for the same service (e.g., personal vs. work GitHub accounts). This ensures they are distinguishable in your authenticator app. +- **Use Service for Identity**: If you only have one account for a service, leaving `Username` empty is fine; the `Service` name will be used for both the issuer and account fields. +- **Special Characters**: Spaces, hyphens, and other special characters in Service or Username are properly URL-encoded. Most authenticator apps (Google Authenticator, Authy, Microsoft Authenticator) decode and display them correctly. + +### Example + +If you have a credential with: +- **Service**: `GitHub - Work` +- **Username**: `dev-admin` + +The QR code will show as **GitHub - Work (dev-admin)** in most authenticator apps. diff --git a/docs/02-guides/tui-guide.md b/docs/02-guides/tui-guide.md index c90a1800..bf8a51d9 100644 --- a/docs/02-guides/tui-guide.md +++ b/docs/02-guides/tui-guide.md @@ -66,6 +66,7 @@ Both modes access the same encrypted vault file (`~/.pass-cli/vault.enc`). | `u` | Copy username to clipboard | Detail panel | | `l` | Copy URL to clipboard | Detail panel | | `n` | Copy notes to clipboard | Detail panel | +| `t` | Copy TOTP code to clipboard | Detail panel | #### View Controls diff --git a/docs/03-reference/command-reference.md b/docs/03-reference/command-reference.md index c61da7d3..a5f29afe 100644 --- a/docs/03-reference/command-reference.md +++ b/docs/03-reference/command-reference.md @@ -178,6 +178,8 @@ pass-cli add [flags] | `--category` | `-c` | string | Category for organizing credentials (e.g., 'Cloud', 'Databases') | | `--url` | | string | Service URL | | `--notes` | | string | Additional notes | +| `--totp` | | bool | Prompt for TOTP secret interactively | +| `--totp-uri` | | string | TOTP URI (otpauth://totp/...) | #### Examples @@ -217,8 +219,16 @@ pass-cli add github \ -p secret123 \ --url https://github.com \ --notes "Work account" + +# Add credential with TOTP (interactive prompt for secret) +pass-cli add github --totp + +# Add credential with TOTP URI directly +pass-cli add github --totp-uri "otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP&issuer=GitHub" ``` +> **Tip**: When adding TOTP, the `Service` and `Username` fields are used as defaults for the QR code's issuer and account name. See the [TOTP & 2FA Guide](../02-guides/totp-guide) for details on how these fields are used. + #### Interactive Prompts When not using flags, you'll be prompted: @@ -265,6 +275,9 @@ pass-cli get [flags] | `--field` | `-f` | string | Extract specific field | | `--no-clipboard` | | bool | Skip clipboard copy | | `--masked` | | bool | Display password as asterisks | +| `--totp` | | bool | Generate and display TOTP code | +| `--totp-qr` | | bool | Display TOTP QR code in terminal | +| `--totp-qr-file` | | string | Export TOTP QR code to PNG file | #### Field Options @@ -301,8 +314,31 @@ pass-cli get github --no-clipboard # Display with masked password pass-cli get github --masked + +# Generate TOTP code (if TOTP configured for credential) +pass-cli get github --totp + +# TOTP code only (for scripts) +pass-cli get github --totp --quiet + +# Display TOTP QR code in terminal (to add to another device) +pass-cli get github --totp-qr + +# Export TOTP QR code to file (use with caution - contains secret) +pass-cli get github --totp-qr-file totp-github.png ``` +#### TOTP URI Labeling (Service & Username) + +When generating a TOTP QR code or URI, Pass-CLI uses the following fields to identify the account in your authenticator app: + +- **Issuer**: Uses `TOTPIssuer` field, or falls back to `Service` name if empty. +- **Account**: Uses `Username` field, or falls back to `Service` name if empty. + +**Best Practice**: Set the `Username` field to distinguish between multiple accounts at the same service. If you only have one account, leaving `Username` empty is fine as the service name will be used. + +For more details on TOTP configuration and usage, see the [TOTP & 2FA Guide](../02-guides/totp-guide). + #### Output Examples **Default output:** @@ -328,6 +364,12 @@ $ pass-cli get github --field username --quiet user@example.com ``` +**TOTP code generation:** +```bash +$ pass-cli get github --totp +123456 +``` + #### Notes - Clipboard auto-clears after 5 seconds @@ -441,6 +483,8 @@ pass-cli update [flags] | `--category` | | string | New category | | `--url` | | string | New URL | | `--notes` | | string | New notes | +| `--totp-uri` | | string | New TOTP URI (otpauth://totp/...) | +| `--clear-totp` | | bool | Clear TOTP configuration | | `--clear-category` | | bool | Clear category field to empty | | `--clear-notes` | | bool | Clear notes field to empty | | `--clear-url` | | bool | Clear URL field to empty | @@ -464,6 +508,12 @@ pass-cli update github --notes "Updated account info" # Update category pass-cli update github --category "Work" +# Update TOTP configuration +pass-cli update github --totp-uri "otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP&issuer=GitHub" + +# Clear TOTP configuration +pass-cli update github --clear-totp + # Generate new random password (16 characters) pass-cli update github --generate @@ -1785,6 +1835,7 @@ pass-cli | `e` | Edit credential | | `d` | Delete credential | | `c` | Copy password | +| `t` | Copy TOTP code | | `q` | Quit | #### See Also diff --git a/docs/_index.md b/docs/_index.md index dd823c65..0b491fb3 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -19,6 +19,7 @@ Welcome to the **pass-cli** documentation. pass-cli is a secure, cross-platform - [Command Reference](03-reference/command-reference) - Complete command reference - [Recovery Phrase](02-guides/recovery-phrase) - BIP39 recovery phrase setup and usage - [Backup & Restore Guide](02-guides/backup-restore) - Manual vault backup management +- [TOTP & 2FA Support](02-guides/totp-guide) - Store and generate 2FA codes - [Security Architecture](03-reference/security-architecture) - Security features and cryptography - [Troubleshooting](04-troubleshooting/_index) - Common issues and solutions by category diff --git a/go.mod b/go.mod index f21f93be..b97c45ce 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/clipperhouse/displaywidth v0.6.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect @@ -31,16 +32,19 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mdp/qrterminal/v3 v3.2.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.1.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -50,4 +54,5 @@ require ( golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 01429bd8..45d3543c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s= github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -11,6 +13,7 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -43,11 +46,15 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= @@ -60,6 +67,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -69,6 +78,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -82,8 +93,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -143,3 +156,5 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/security/audit.go b/internal/security/audit.go index 8b7196ab..7d69db16 100644 --- a/internal/security/audit.go +++ b/internal/security/audit.go @@ -46,6 +46,12 @@ const ( // Backup operations (001-add-manual-vault) EventBackupCreate = "backup_create" // FR-017: Manual backup creation EventBackupRestore = "backup_restore" // FR-017: Vault restoration from backup + + // TOTP operations (feature/totp-support) + EventTOTPAccess = "totp_access" // TOTP code generated/accessed + EventTOTPAdd = "totp_add" // TOTP secret added to credential + EventTOTPUpdate = "totp_update" // TOTP secret updated + EventTOTPClear = "totp_clear" // TOTP secret removed from credential ) // Outcome constants diff --git a/internal/vault/totp.go b/internal/vault/totp.go new file mode 100644 index 00000000..970b953a --- /dev/null +++ b/internal/vault/totp.go @@ -0,0 +1,429 @@ +package vault + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/mdp/qrterminal/v3" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "github.com/skip2/go-qrcode" +) + +// TimeSyncThreshold is the maximum acceptable clock drift in seconds +const TimeSyncThreshold = 30 + +// TimeSyncResult holds the result of a time sync check +type TimeSyncResult struct { + InSync bool // Whether the clock is within acceptable drift + Drift time.Duration // Estimated drift from server time + Checked bool // Whether the check was performed + Error error // Any error during the check + ServerTime time.Time // The server's reported time + LocalTime time.Time // Local time when check was performed +} + +// CheckTimeSync verifies that the local system clock is reasonably accurate +// by comparing against an HTTP server's Date header. +// Returns a TimeSyncResult with drift information. +func CheckTimeSync() TimeSyncResult { + result := TimeSyncResult{ + LocalTime: time.Now(), + } + + // Use a reliable, fast endpoint - just need the Date header + client := &http.Client{ + Timeout: 5 * time.Second, + } + + // HEAD request to minimize data transfer + resp, err := client.Head("https://www.google.com") + if err != nil { + result.Error = err + return result + } + defer func() { _ = resp.Body.Close() }() + + result.Checked = true + + // Parse the Date header + dateHeader := resp.Header.Get("Date") + if dateHeader == "" { + result.Error = fmt.Errorf("no Date header in response") + return result + } + + serverTime, err := http.ParseTime(dateHeader) + if err != nil { + result.Error = fmt.Errorf("failed to parse Date header: %w", err) + return result + } + + result.ServerTime = serverTime + result.Drift = result.LocalTime.Sub(serverTime) + + // Check if drift is within acceptable range + if result.Drift < 0 { + result.Drift = -result.Drift // Absolute value + } + result.InSync = result.Drift <= TimeSyncThreshold*time.Second + + return result +} + +// FormatTimeSyncWarning returns a user-friendly warning message if time is out of sync +func FormatTimeSyncWarning(result TimeSyncResult) string { + if !result.Checked { + if result.Error != nil { + return fmt.Sprintf("⚠️ Could not verify time sync: %v", result.Error) + } + return "" + } + + if result.InSync { + return "" // No warning needed + } + + direction := "ahead" + if result.LocalTime.Before(result.ServerTime) { + direction = "behind" + } + + return fmt.Sprintf("⚠️ Warning: System clock is %v %s. TOTP codes may not work!\n"+ + " Please sync your system time.", result.Drift.Round(time.Second), direction) +} + +// TOTPConfig holds parsed TOTP configuration from an otpauth:// URI +type TOTPConfig struct { + Secret string // Base32 encoded secret + Algorithm string // SHA1, SHA256, SHA512 + Digits int // 6 or 8 + Period int // seconds + Issuer string // Service/issuer name + Account string // Account name (usually email) +} + +// DefaultTOTPConfig returns default TOTP configuration values +func DefaultTOTPConfig() TOTPConfig { + return TOTPConfig{ + Algorithm: "SHA1", + Digits: 6, + Period: 30, + } +} + +// ParseTOTPURI parses an otpauth:// URI and returns TOTP configuration +// Supports both otpauth://totp/... and raw base32 secrets +func ParseTOTPURI(uri string) (*TOTPConfig, error) { + uri = strings.TrimSpace(uri) + + // Check if it's a raw base32 secret (no otpauth:// prefix) + if !strings.HasPrefix(strings.ToLower(uri), "otpauth://") { + // Validate as base32 secret + if err := ValidateTOTPSecret(uri); err != nil { + return nil, err + } + config := DefaultTOTPConfig() + config.Secret = strings.ToUpper(uri) + return &config, nil + } + + // Parse as otpauth:// URI using the library + key, err := otp.NewKeyFromURL(uri) + if err != nil { + return nil, fmt.Errorf("invalid otpauth URI: %w", err) + } + + // Validate it's a TOTP URI (not HOTP) + if key.Type() != "totp" { + return nil, fmt.Errorf("unsupported OTP type: %s (only totp is supported)", key.Type()) + } + + // Safely convert period - cap to reasonable range then convert + // TOTP periods are typically 30s, max 5 minutes is reasonable + keyPeriod := key.Period() + periodInt := 30 // Default to standard period + if keyPeriod > 0 && keyPeriod <= 300 { + // #nosec G115 -- keyPeriod is bounds-checked to max 300, safe for int + periodInt = int(keyPeriod) + } + + config := &TOTPConfig{ + Secret: key.Secret(), + Issuer: key.Issuer(), + Account: key.AccountName(), + Period: periodInt, + Digits: key.Digits().Length(), + } + + // Map algorithm + switch key.Algorithm() { + case otp.AlgorithmSHA1: + config.Algorithm = "SHA1" + case otp.AlgorithmSHA256: + config.Algorithm = "SHA256" + case otp.AlgorithmSHA512: + config.Algorithm = "SHA512" + default: + config.Algorithm = "SHA1" + } + + // Apply defaults for zero values + if config.Period == 0 { + config.Period = 30 + } + if config.Digits == 0 { + config.Digits = 6 + } + + return config, nil +} + +// ValidateTOTPSecret validates that a string is a valid base32 TOTP secret +func ValidateTOTPSecret(secret string) error { + secret = strings.TrimSpace(strings.ToUpper(secret)) + if secret == "" { + return fmt.Errorf("TOTP secret cannot be empty") + } + + // Check for valid base32 characters (A-Z, 2-7, =) + for _, c := range secret { + isUpperAlpha := c >= 'A' && c <= 'Z' + isValidDigit := c >= '2' && c <= '7' + isPadding := c == '=' + if !isUpperAlpha && !isValidDigit && !isPadding { + return fmt.Errorf("invalid base32 character in TOTP secret: %c", c) + } + } + + // Try to generate a code to fully validate the secret + _, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + return fmt.Errorf("invalid TOTP secret: %w", err) + } + + return nil +} + +// GenerateTOTPCode generates a TOTP code for the given credential +// Returns the code and remaining validity in seconds +func GenerateTOTPCode(cred *Credential) (string, int, error) { + if cred.TOTPSecret == "" { + return "", 0, fmt.Errorf("no TOTP configured for this credential") + } + + // Determine algorithm + algo := otp.AlgorithmSHA1 + switch strings.ToUpper(cred.TOTPAlgorithm) { + case "SHA256": + algo = otp.AlgorithmSHA256 + case "SHA512": + algo = otp.AlgorithmSHA512 + } + + // Determine digits + digits := otp.DigitsSix + if cred.TOTPDigits == 8 { + digits = otp.DigitsEight + } + + // Determine period with bounds check (TOTP periods are typically 30s, max 5 min) + period := uint(30) + if cred.TOTPPeriod > 0 && cred.TOTPPeriod <= 300 { + period = uint(cred.TOTPPeriod) + } + + // Generate code + now := time.Now() + code, err := totp.GenerateCodeCustom(cred.TOTPSecret, now, totp.ValidateOpts{ + Period: period, + Digits: digits, + Algorithm: algo, + }) + if err != nil { + return "", 0, fmt.Errorf("failed to generate TOTP code: %w", err) + } + + // Calculate remaining validity + // period is bounds-checked above (max 300), safe to convert + epoch := now.Unix() + // #nosec G115 -- period is bounds-checked to max 300, safe for int + periodInt := int(period) + remaining := periodInt - int(epoch%int64(periodInt)) + + return code, remaining, nil +} + +// HasTOTP returns true if the credential has TOTP configured +func (c *Credential) HasTOTP() bool { + return c.TOTPSecret != "" +} + +// GetTOTPCode generates and returns the current TOTP code for this credential +func (c *Credential) GetTOTPCode() (string, int, error) { + return GenerateTOTPCode(c) +} + +// SetTOTPFromURI parses a TOTP URI and sets the credential's TOTP fields +func (c *Credential) SetTOTPFromURI(uri string) error { + config, err := ParseTOTPURI(uri) + if err != nil { + return err + } + + c.TOTPSecret = config.Secret + c.TOTPAlgorithm = config.Algorithm + c.TOTPDigits = config.Digits + c.TOTPPeriod = config.Period + if config.Issuer != "" { + c.TOTPIssuer = config.Issuer + } + + return nil +} + +// ClearTOTP removes all TOTP configuration from the credential +func (c *Credential) ClearTOTP() { + c.TOTPSecret = "" + c.TOTPAlgorithm = "" + c.TOTPDigits = 0 + c.TOTPPeriod = 0 + c.TOTPIssuer = "" +} + +// BuildTOTPURI constructs an otpauth:// URI from the credential's TOTP config +// Useful for exporting or displaying QR codes +// +// The URI follows the otpauth:// format: +// otpauth://totp/ISSUER:ACCOUNT?secret=SECRET&issuer=ISSUER&algorithm=ALG&digits=N&period=N +// +// Returns an error if: +// - No TOTP secret is configured +// - Both Service and Username are empty (no account identity) +// - Algorithm is not SHA1, SHA256, or SHA512 +// - Digits is not 6 or 8 +// - Period is outside 1-300 seconds range +func (c *Credential) BuildTOTPURI() (string, error) { + if c.TOTPSecret == "" { + return "", fmt.Errorf("no TOTP configured for this credential") + } + + // Normalize secret: trim whitespace and uppercase + secret := strings.TrimSpace(strings.ToUpper(c.TOTPSecret)) + if secret == "" { + return "", fmt.Errorf("TOTP secret is empty after normalization") + } + + // Validate and normalize algorithm + algorithm := strings.ToUpper(c.TOTPAlgorithm) + if algorithm == "" { + algorithm = "SHA1" + } + switch algorithm { + case "SHA1", "SHA256", "SHA512": + // Valid algorithms + default: + return "", fmt.Errorf("unsupported TOTP algorithm: %s (must be SHA1, SHA256, or SHA512)", algorithm) + } + + // Validate digits + digits := c.TOTPDigits + if digits == 0 { + digits = 6 + } + if digits != 6 && digits != 8 { + return "", fmt.Errorf("unsupported TOTP digits: %d (must be 6 or 8)", digits) + } + + // Validate period + period := c.TOTPPeriod + if period == 0 { + period = 30 + } + if period < 1 || period > 300 { + return "", fmt.Errorf("TOTP period out of range: %d (must be 1-300 seconds)", period) + } + + // Get issuer and account + issuer := c.TOTPIssuer + if issuer == "" { + issuer = c.Service + } + account := c.Username + if account == "" { + account = c.Service + } + + // Validate we have an account identity + if account == "" { + return "", fmt.Errorf("cannot build TOTP URI: no account identity (Service and Username are both empty)") + } + + // Build label (issuer:account or just account) + // The label appears in the authenticator app as the credential name + label := url.PathEscape(account) + if issuer != "" { + label = url.PathEscape(issuer) + ":" + url.PathEscape(account) + } + + // Build query parameters + // secret is already base32 encoded - use directly without re-encoding + params := url.Values{} + params.Set("secret", secret) + if issuer != "" { + params.Set("issuer", issuer) + } + // Only include non-default values to keep URI clean + if algorithm != "SHA1" { + params.Set("algorithm", algorithm) + } + if digits != 6 { + params.Set("digits", strconv.Itoa(digits)) + } + if period != 30 { + params.Set("period", strconv.Itoa(period)) + } + + // Build URI with proper encoding + // Use strings.ReplaceAll to convert + to %20 for maximum authenticator app compatibility + // (url.Values.Encode() uses + for spaces, but some apps expect %20) + queryString := strings.ReplaceAll(params.Encode(), "+", "%20") + + return fmt.Sprintf("otpauth://totp/%s?%s", label, queryString), nil +} + +// DisplayQRCode displays a QR code in the terminal for the credential's TOTP configuration +// This allows users to scan the code with their authenticator app +func (c *Credential) DisplayQRCode(writer *os.File) error { + uri, err := c.BuildTOTPURI() + if err != nil { + return err + } + + config := qrterminal.Config{ + Level: qrterminal.L, + Writer: writer, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + QuietZone: 1, + } + + qrterminal.GenerateWithConfig(uri, config) + return nil +} + +// ExportQRCode exports a QR code to a PNG file for the credential's TOTP configuration +// size is the image width/height in pixels (e.g., 256) +func (c *Credential) ExportQRCode(filename string, size int) error { + uri, err := c.BuildTOTPURI() + if err != nil { + return err + } + + return qrcode.WriteFile(uri, qrcode.Medium, size, filename) +} diff --git a/internal/vault/totp_test.go b/internal/vault/totp_test.go new file mode 100644 index 00000000..69eb2bc6 --- /dev/null +++ b/internal/vault/totp_test.go @@ -0,0 +1,801 @@ +package vault + +import ( + "fmt" + "net/url" + "strings" + "testing" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +func TestParseTOTPURI_ValidFullURI(t *testing.T) { + uri := "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA256&digits=6&period=30" + + config, err := ParseTOTPURI(uri) + if err != nil { + t.Fatalf("ParseTOTPURI failed: %v", err) + } + + if config.Secret != "JBSWY3DPEHPK3PXP" { + t.Errorf("expected secret JBSWY3DPEHPK3PXP, got %s", config.Secret) + } + if config.Issuer != "GitHub" { + t.Errorf("expected issuer GitHub, got %s", config.Issuer) + } + if config.Algorithm != "SHA256" { + t.Errorf("expected algorithm SHA256, got %s", config.Algorithm) + } + if config.Digits != 6 { + t.Errorf("expected digits 6, got %d", config.Digits) + } + if config.Period != 30 { + t.Errorf("expected period 30, got %d", config.Period) + } +} + +func TestParseTOTPURI_MinimalURI(t *testing.T) { + uri := "otpauth://totp/service?secret=JBSWY3DPEHPK3PXP" + + config, err := ParseTOTPURI(uri) + if err != nil { + t.Fatalf("ParseTOTPURI failed: %v", err) + } + + // Check defaults are applied + if config.Algorithm != "SHA1" { + t.Errorf("expected default algorithm SHA1, got %s", config.Algorithm) + } + if config.Digits != 6 { + t.Errorf("expected default digits 6, got %d", config.Digits) + } + if config.Period != 30 { + t.Errorf("expected default period 30, got %d", config.Period) + } +} + +func TestParseTOTPURI_RawBase32Secret(t *testing.T) { + secret := "JBSWY3DPEHPK3PXP" + + config, err := ParseTOTPURI(secret) + if err != nil { + t.Fatalf("ParseTOTPURI failed: %v", err) + } + + if config.Secret != "JBSWY3DPEHPK3PXP" { + t.Errorf("expected secret JBSWY3DPEHPK3PXP, got %s", config.Secret) + } + if config.Algorithm != "SHA1" { + t.Errorf("expected default algorithm SHA1, got %s", config.Algorithm) + } +} + +func TestParseTOTPURI_LowercaseSecret(t *testing.T) { + // Google Authenticator sometimes uses lowercase + secret := "jbswy3dpehpk3pxp" + + config, err := ParseTOTPURI(secret) + if err != nil { + t.Fatalf("ParseTOTPURI failed: %v", err) + } + + // Should be normalized to uppercase + if config.Secret != "JBSWY3DPEHPK3PXP" { + t.Errorf("expected uppercase secret JBSWY3DPEHPK3PXP, got %s", config.Secret) + } +} + +func TestParseTOTPURI_InvalidScheme(t *testing.T) { + uri := "https://totp/service?secret=JBSWY3DPEHPK3PXP" + + _, err := ParseTOTPURI(uri) + if err == nil { + t.Error("expected error for invalid scheme, got nil") + } +} + +func TestParseTOTPURI_HOTPNotSupported(t *testing.T) { + uri := "otpauth://hotp/service?secret=JBSWY3DPEHPK3PXP&counter=0" + + _, err := ParseTOTPURI(uri) + if err == nil { + t.Error("expected error for HOTP type, got nil") + } + if !strings.Contains(err.Error(), "hotp") { + t.Errorf("expected error to mention hotp, got: %v", err) + } +} + +func TestParseTOTPURI_InvalidBase32(t *testing.T) { + secret := "INVALID!@#SECRET" + + _, err := ParseTOTPURI(secret) + if err == nil { + t.Error("expected error for invalid base32, got nil") + } +} + +func TestValidateTOTPSecret_Valid(t *testing.T) { + secrets := []string{ + "JBSWY3DPEHPK3PXP", + "GEZDGNBVGY3TQOJQ", + "jbswy3dpehpk3pxp", // lowercase should be accepted + } + + for _, secret := range secrets { + err := ValidateTOTPSecret(secret) + if err != nil { + t.Errorf("ValidateTOTPSecret(%q) failed: %v", secret, err) + } + } +} + +func TestValidateTOTPSecret_Empty(t *testing.T) { + err := ValidateTOTPSecret("") + if err == nil { + t.Error("expected error for empty secret, got nil") + } +} + +func TestValidateTOTPSecret_InvalidChars(t *testing.T) { + err := ValidateTOTPSecret("INVALID!SECRET") + if err == nil { + t.Error("expected error for invalid characters, got nil") + } +} + +func TestGenerateTOTPCode_Success(t *testing.T) { + cred := &Credential{ + Service: "test", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: "SHA1", + TOTPDigits: 6, + TOTPPeriod: 30, + } + + code, remaining, err := GenerateTOTPCode(cred) + if err != nil { + t.Fatalf("GenerateTOTPCode failed: %v", err) + } + + // Code should be 6 digits + if len(code) != 6 { + t.Errorf("expected 6-digit code, got %d digits: %s", len(code), code) + } + + // Remaining should be between 1 and 30 + if remaining < 1 || remaining > 30 { + t.Errorf("expected remaining between 1-30, got %d", remaining) + } +} + +func TestGenerateTOTPCode_NoTOTPConfigured(t *testing.T) { + cred := &Credential{ + Service: "test", + } + + _, _, err := GenerateTOTPCode(cred) + if err == nil { + t.Error("expected error for no TOTP configured, got nil") + } +} + +func TestGenerateTOTPCode_8Digits(t *testing.T) { + cred := &Credential{ + Service: "test", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: "SHA1", + TOTPDigits: 8, + TOTPPeriod: 30, + } + + code, _, err := GenerateTOTPCode(cred) + if err != nil { + t.Fatalf("GenerateTOTPCode failed: %v", err) + } + + if len(code) != 8 { + t.Errorf("expected 8-digit code, got %d digits: %s", len(code), code) + } +} + +func TestGenerateTOTPCode_SHA256(t *testing.T) { + cred := &Credential{ + Service: "test", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: "SHA256", + TOTPDigits: 6, + TOTPPeriod: 30, + } + + code, _, err := GenerateTOTPCode(cred) + if err != nil { + t.Fatalf("GenerateTOTPCode failed: %v", err) + } + + if len(code) != 6 { + t.Errorf("expected 6-digit code, got %d digits: %s", len(code), code) + } +} + +func TestCredential_HasTOTP(t *testing.T) { + tests := []struct { + name string + cred *Credential + expected bool + }{ + { + name: "with TOTP", + cred: &Credential{TOTPSecret: "JBSWY3DPEHPK3PXP"}, + expected: true, + }, + { + name: "without TOTP", + cred: &Credential{}, + expected: false, + }, + { + name: "empty secret", + cred: &Credential{TOTPSecret: ""}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cred.HasTOTP(); got != tt.expected { + t.Errorf("HasTOTP() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestCredential_SetTOTPFromURI(t *testing.T) { + cred := &Credential{Service: "github"} + uri := "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA256&digits=8&period=60" + + err := cred.SetTOTPFromURI(uri) + if err != nil { + t.Fatalf("SetTOTPFromURI failed: %v", err) + } + + if cred.TOTPSecret != "JBSWY3DPEHPK3PXP" { + t.Errorf("expected secret JBSWY3DPEHPK3PXP, got %s", cred.TOTPSecret) + } + if cred.TOTPAlgorithm != "SHA256" { + t.Errorf("expected algorithm SHA256, got %s", cred.TOTPAlgorithm) + } + if cred.TOTPDigits != 8 { + t.Errorf("expected digits 8, got %d", cred.TOTPDigits) + } + if cred.TOTPPeriod != 60 { + t.Errorf("expected period 60, got %d", cred.TOTPPeriod) + } + if cred.TOTPIssuer != "GitHub" { + t.Errorf("expected issuer GitHub, got %s", cred.TOTPIssuer) + } +} + +func TestCredential_ClearTOTP(t *testing.T) { + cred := &Credential{ + Service: "github", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: "SHA256", + TOTPDigits: 8, + TOTPPeriod: 60, + TOTPIssuer: "GitHub", + } + + cred.ClearTOTP() + + if cred.TOTPSecret != "" { + t.Errorf("expected empty secret, got %s", cred.TOTPSecret) + } + if cred.TOTPAlgorithm != "" { + t.Errorf("expected empty algorithm, got %s", cred.TOTPAlgorithm) + } + if cred.TOTPDigits != 0 { + t.Errorf("expected digits 0, got %d", cred.TOTPDigits) + } + if cred.TOTPPeriod != 0 { + t.Errorf("expected period 0, got %d", cred.TOTPPeriod) + } + if cred.TOTPIssuer != "" { + t.Errorf("expected empty issuer, got %s", cred.TOTPIssuer) + } +} + +func TestDefaultTOTPConfig(t *testing.T) { + config := DefaultTOTPConfig() + + if config.Algorithm != "SHA1" { + t.Errorf("expected default algorithm SHA1, got %s", config.Algorithm) + } + if config.Digits != 6 { + t.Errorf("expected default digits 6, got %d", config.Digits) + } + if config.Period != 30 { + t.Errorf("expected default period 30, got %d", config.Period) + } +} + +func TestFormatTimeSyncWarning_InSync(t *testing.T) { + result := TimeSyncResult{ + Checked: true, + InSync: true, + Drift: 5 * time.Second, + } + + warning := FormatTimeSyncWarning(result) + if warning != "" { + t.Errorf("expected no warning for in-sync time, got: %s", warning) + } +} + +func TestFormatTimeSyncWarning_OutOfSync(t *testing.T) { + result := TimeSyncResult{ + Checked: true, + InSync: false, + Drift: 2 * time.Minute, + LocalTime: time.Now(), + ServerTime: time.Now().Add(-2 * time.Minute), + } + + warning := FormatTimeSyncWarning(result) + if warning == "" { + t.Error("expected warning for out-of-sync time") + } + if !strings.Contains(warning, "Warning") { + t.Errorf("expected warning message to contain 'Warning', got: %s", warning) + } +} + +func TestFormatTimeSyncWarning_CheckFailed(t *testing.T) { + result := TimeSyncResult{ + Checked: false, + Error: fmt.Errorf("network error"), + } + + warning := FormatTimeSyncWarning(result) + if warning == "" { + t.Error("expected warning for failed check") + } + if !strings.Contains(warning, "Could not verify") { + t.Errorf("expected 'Could not verify' in warning, got: %s", warning) + } +} + +func TestFormatTimeSyncWarning_NotChecked(t *testing.T) { + result := TimeSyncResult{ + Checked: false, + } + + warning := FormatTimeSyncWarning(result) + if warning != "" { + t.Errorf("expected no warning when not checked and no error, got: %s", warning) + } +} + +func TestBuildTOTPURI_SecretMatchesCodeGeneration(t *testing.T) { + // This test ensures that the secret in the generated QR code URI + // matches the secret used for TOTP code generation. + // This was a bug where totp.Generate() re-encoded the already-encoded secret. + secret := "JBSWY3DPEHPK3PXP" + cred := &Credential{ + Service: "TestService", + Username: "testuser", + TOTPSecret: secret, + } + + // Build the URI + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed: %v", err) + } + + // Verify the URI contains the exact same secret (not re-encoded) + if !strings.Contains(uri, "secret="+secret) { + t.Errorf("URI should contain original secret=%s, got URI: %s", secret, uri) + } + + // Parse the URI back and verify codes match + parsedKey, err := otp.NewKeyFromURL(uri) + if err != nil { + t.Fatalf("Failed to parse generated URI: %v", err) + } + + // The secret from the parsed URI should match the original + if parsedKey.Secret() != secret { + t.Errorf("Parsed secret=%s does not match original=%s", parsedKey.Secret(), secret) + } + + // Most importantly: codes generated from both should match + // Use the same timestamp for both to avoid flakiness at TOTP step boundaries + now := time.Now() + codeFromCred, err := totp.GenerateCode(cred.TOTPSecret, now) + if err != nil { + t.Fatalf("GenerateCode from credential failed: %v", err) + } + + codeFromURI, err := totp.GenerateCode(parsedKey.Secret(), now) + if err != nil { + t.Fatalf("GenerateCode from URI failed: %v", err) + } + + if codeFromCred != codeFromURI { + t.Errorf("Code mismatch! Credential generated %s, URI would generate %s", codeFromCred, codeFromURI) + } +} + +func TestBuildTOTPURI_NoTOTPConfigured(t *testing.T) { + cred := &Credential{ + Service: "test", + } + + _, err := cred.BuildTOTPURI() + if err == nil { + t.Error("expected error for no TOTP configured, got nil") + } +} + +func TestBuildTOTPURI_DefaultValues(t *testing.T) { + cred := &Credential{ + Service: "github", + Username: "user@example.com", + TOTPSecret: "JBSWY3DPEHPK3PXP", + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed: %v", err) + } + + // Should have the secret + if !strings.Contains(uri, "secret=JBSWY3DPEHPK3PXP") { + t.Errorf("URI missing secret, got: %s", uri) + } + + // Should use service as issuer when not specified + if !strings.Contains(uri, "issuer=github") { + t.Errorf("URI should use service as issuer, got: %s", uri) + } + + // Should NOT have algorithm param (default SHA1 is omitted) + if strings.Contains(uri, "algorithm=") { + t.Errorf("URI should not include default algorithm=SHA1, got: %s", uri) + } + + // Should NOT have digits param (default 6 is omitted) + if strings.Contains(uri, "digits=") { + t.Errorf("URI should not include default digits=6, got: %s", uri) + } + + // Should NOT have period param (default 30 is omitted) + if strings.Contains(uri, "period=") { + t.Errorf("URI should not include default period=30, got: %s", uri) + } +} + +func TestBuildTOTPURI_NonDefaultValues(t *testing.T) { + cred := &Credential{ + Service: "github", + Username: "user@example.com", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: "SHA256", + TOTPDigits: 8, + TOTPPeriod: 60, + TOTPIssuer: "GitHub Inc", + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed: %v", err) + } + + // Parse the URI to check values + parsedURL, err := url.Parse(uri) + if err != nil { + t.Fatalf("Failed to parse URI: %v", err) + } + + params := parsedURL.Query() + + if params.Get("algorithm") != "SHA256" { + t.Errorf("expected algorithm=SHA256, got: %s", params.Get("algorithm")) + } + if params.Get("digits") != "8" { + t.Errorf("expected digits=8, got: %s", params.Get("digits")) + } + if params.Get("period") != "60" { + t.Errorf("expected period=60, got: %s", params.Get("period")) + } + if params.Get("issuer") != "GitHub Inc" { + t.Errorf("expected issuer='GitHub Inc', got: %s", params.Get("issuer")) + } +} + +func TestBuildTOTPURI_SpecialCharactersInLabel(t *testing.T) { + cred := &Credential{ + Service: "My Service", + Username: "user@example.com", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPIssuer: "Company Name", + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed: %v", err) + } + + // Should be a valid URI + _, err = url.Parse(uri) + if err != nil { + t.Errorf("Generated URI is not valid: %v", err) + } + + // Should start with otpauth://totp/ + if !strings.HasPrefix(uri, "otpauth://totp/") { + t.Errorf("URI should start with otpauth://totp/, got: %s", uri) + } +} + +func TestBuildTOTPURI_SpaceEncodingAsPercent20(t *testing.T) { + // Verify that spaces in issuer are encoded as %20 (not +) + // for maximum compatibility with authenticator apps + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPIssuer: "Company Name", + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed: %v", err) + } + + // Should use %20 for spaces, not + + if strings.Contains(uri, "issuer=Company+Name") { + t.Errorf("URI should use %%20 for spaces, not +, got: %s", uri) + } + if !strings.Contains(uri, "issuer=Company%20Name") { + t.Errorf("URI should contain issuer=Company%%20Name, got: %s", uri) + } + + // Verify it still parses correctly + parsedKey, err := otp.NewKeyFromURL(uri) + if err != nil { + t.Fatalf("Failed to parse URI with %%20 spaces: %v", err) + } + if parsedKey.Issuer() != "Company Name" { + t.Errorf("Parsed issuer should be 'Company Name', got: %s", parsedKey.Issuer()) + } +} + +func TestBuildTOTPURI_SecretNormalization(t *testing.T) { + tests := []struct { + name string + inputSecret string + expectedSecret string + }{ + { + name: "lowercase to uppercase", + inputSecret: "jbswy3dpehpk3pxp", + expectedSecret: "JBSWY3DPEHPK3PXP", + }, + { + name: "with leading/trailing whitespace", + inputSecret: " JBSWY3DPEHPK3PXP ", + expectedSecret: "JBSWY3DPEHPK3PXP", + }, + { + name: "mixed case with whitespace", + inputSecret: " JbSwY3DpEhPk3PxP ", + expectedSecret: "JBSWY3DPEHPK3PXP", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: tt.inputSecret, + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed: %v", err) + } + + if !strings.Contains(uri, "secret="+tt.expectedSecret) { + t.Errorf("URI should contain normalized secret=%s, got: %s", tt.expectedSecret, uri) + } + }) + } +} + +func TestBuildTOTPURI_InvalidAlgorithm(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: "MD5", // Invalid algorithm + } + + _, err := cred.BuildTOTPURI() + if err == nil { + t.Error("expected error for invalid algorithm MD5, got nil") + } + if !strings.Contains(err.Error(), "unsupported TOTP algorithm") { + t.Errorf("error should mention unsupported algorithm, got: %v", err) + } +} + +func TestBuildTOTPURI_InvalidDigits(t *testing.T) { + tests := []struct { + name string + digits int + }{ + {"too few digits", 4}, + {"too many digits", 10}, + {"odd number", 7}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPDigits: tt.digits, + } + + _, err := cred.BuildTOTPURI() + if err == nil { + t.Errorf("expected error for digits=%d, got nil", tt.digits) + } + if !strings.Contains(err.Error(), "unsupported TOTP digits") { + t.Errorf("error should mention unsupported digits, got: %v", err) + } + }) + } +} + +func TestBuildTOTPURI_InvalidPeriod(t *testing.T) { + tests := []struct { + name string + period int + }{ + {"zero period", 0}, // Note: 0 gets defaulted to 30, so this won't error + {"negative period", -1}, // This will error (but Go's int won't go negative from 0 default) + {"too long period", 301}, // Over 5 minutes + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPPeriod: tt.period, + } + + _, err := cred.BuildTOTPURI() + // 0 gets defaulted to 30, so only non-zero invalid values error + if tt.period == 0 { + if err != nil { + t.Errorf("period=0 should default to 30, not error: %v", err) + } + return + } + if err == nil { + t.Errorf("expected error for period=%d, got nil", tt.period) + } + if !strings.Contains(err.Error(), "TOTP period out of range") { + t.Errorf("error should mention period out of range, got: %v", err) + } + }) + } +} + +func TestBuildTOTPURI_EmptyIdentity(t *testing.T) { + // Both Service and Username are empty - should error + cred := &Credential{ + TOTPSecret: "JBSWY3DPEHPK3PXP", + } + + _, err := cred.BuildTOTPURI() + if err == nil { + t.Error("expected error when both Service and Username are empty, got nil") + } + if !strings.Contains(err.Error(), "no account identity") { + t.Errorf("error should mention no account identity, got: %v", err) + } +} + +func TestBuildTOTPURI_ValidAlgorithms(t *testing.T) { + algorithms := []string{"SHA1", "SHA256", "SHA512", "sha1", "sha256", "sha512"} + + for _, algo := range algorithms { + t.Run(algo, func(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPAlgorithm: algo, + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed for algorithm %s: %v", algo, err) + } + + // Verify it parses correctly + _, err = otp.NewKeyFromURL(uri) + if err != nil { + t.Fatalf("Generated URI for algorithm %s is not valid: %v", algo, err) + } + }) + } +} + +func TestBuildTOTPURI_ValidDigits(t *testing.T) { + validDigits := []int{6, 8} + + for _, digits := range validDigits { + t.Run(fmt.Sprintf("digits=%d", digits), func(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPDigits: digits, + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed for digits=%d: %v", digits, err) + } + + // Verify it parses correctly + parsedKey, err := otp.NewKeyFromURL(uri) + if err != nil { + t.Fatalf("Generated URI for digits=%d is not valid: %v", digits, err) + } + if parsedKey.Digits().Length() != digits { + t.Errorf("Expected digits=%d, got %d", digits, parsedKey.Digits().Length()) + } + }) + } +} + +func TestBuildTOTPURI_ValidPeriods(t *testing.T) { + validPeriods := []int{15, 30, 60, 120, 300} + + for _, period := range validPeriods { + t.Run(fmt.Sprintf("period=%d", period), func(t *testing.T) { + cred := &Credential{ + Service: "test", + Username: "user", + TOTPSecret: "JBSWY3DPEHPK3PXP", + TOTPPeriod: period, + } + + uri, err := cred.BuildTOTPURI() + if err != nil { + t.Fatalf("BuildTOTPURI failed for period=%d: %v", period, err) + } + + // Verify it parses correctly + parsedKey, err := otp.NewKeyFromURL(uri) + if err != nil { + t.Fatalf("Generated URI for period=%d is not valid: %v", period, err) + } + if parsedKey.Period() != uint64(period) { + t.Errorf("Expected period=%d, got %d", period, parsedKey.Period()) + } + }) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 73690fbe..7784dbc6 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -76,6 +76,13 @@ type Credential struct { UpdatedAt time.Time `json:"updated_at"` ModifiedCount int `json:"modified_count"` // Number of times credential has been modified UsageRecord map[string]UsageRecord `json:"usage_records"` // Map of location -> UsageRecord + + // TOTP fields for 2FA support (all optional) + TOTPSecret string `json:"totp_secret,omitempty"` // Base32 encoded TOTP secret + TOTPAlgorithm string `json:"totp_algorithm,omitempty"` // SHA1, SHA256, SHA512 (default: SHA1) + TOTPDigits int `json:"totp_digits,omitempty"` // 6 or 8 (default: 6) + TOTPPeriod int `json:"totp_period,omitempty"` // Period in seconds (default: 30) + TOTPIssuer string `json:"totp_issuer,omitempty"` // Issuer name for display } // VaultData is the decrypted vault structure @@ -1065,6 +1072,14 @@ type UpdateOpts struct { Category *string URL *string Notes *string + + // TOTP fields (nil = don't change, non-nil = set value) + TOTPSecret *string // Base32 encoded TOTP secret (empty string clears TOTP) + TOTPAlgorithm *string // SHA1, SHA256, SHA512 + TOTPDigits *int // 6 or 8 + TOTPPeriod *int // Period in seconds + TOTPIssuer *string // Issuer name + ClearTOTP bool // If true, clears all TOTP fields } // CredentialMetadata contains non-sensitive credential information for listing @@ -1081,6 +1096,10 @@ type CredentialMetadata struct { LastAccessed time.Time // Most recent access time Locations []string // List of locations where accessed GitRepositories []string // List of unique git repositories where accessed (for --by-project grouping) + + // TOTP metadata (non-sensitive) + HasTOTP bool // Whether TOTP is configured for this credential + TOTPIssuer string // Issuer name for display } // ListCredentialsWithMetadata returns all credentials with metadata (no passwords) @@ -1131,6 +1150,10 @@ func (v *VaultService) ListCredentialsWithMetadata() ([]CredentialMetadata, erro meta.Locations = locations meta.GitRepositories = gitReposList + // TOTP metadata + meta.HasTOTP = cred.TOTPSecret != "" + meta.TOTPIssuer = cred.TOTPIssuer + metadata = append(metadata, meta) } @@ -1183,6 +1206,51 @@ func (v *VaultService) UpdateCredential(service string, opts UpdateOpts) error { fieldUpdated = true } + // TOTP field updates with audit logging + hadTOTPBefore := credential.TOTPSecret != "" + totpCleared := false + totpAdded := false + totpUpdated := false + + if opts.ClearTOTP { + // Clear all TOTP fields + if hadTOTPBefore { + totpCleared = true + } + credential.TOTPSecret = "" + credential.TOTPAlgorithm = "" + credential.TOTPDigits = 0 + credential.TOTPPeriod = 0 + credential.TOTPIssuer = "" + fieldUpdated = true + } else { + if opts.TOTPSecret != nil { + if hadTOTPBefore { + totpUpdated = true + } else if *opts.TOTPSecret != "" { + totpAdded = true + } + credential.TOTPSecret = *opts.TOTPSecret + fieldUpdated = true + } + if opts.TOTPAlgorithm != nil { + credential.TOTPAlgorithm = *opts.TOTPAlgorithm + fieldUpdated = true + } + if opts.TOTPDigits != nil { + credential.TOTPDigits = *opts.TOTPDigits + fieldUpdated = true + } + if opts.TOTPPeriod != nil { + credential.TOTPPeriod = *opts.TOTPPeriod + fieldUpdated = true + } + if opts.TOTPIssuer != nil { + credential.TOTPIssuer = *opts.TOTPIssuer + fieldUpdated = true + } + } + // Only increment counter if something was actually modified if fieldUpdated { credential.ModifiedCount++ @@ -1197,6 +1265,16 @@ func (v *VaultService) UpdateCredential(service string, opts UpdateOpts) error { // T071: Log credential update (FR-020) v.LogAudit(security.EventCredentialUpdate, security.OutcomeSuccess, service) + + // TOTP-specific audit events + if totpCleared { + v.LogAudit(security.EventTOTPClear, security.OutcomeSuccess, service) + } else if totpAdded { + v.LogAudit(security.EventTOTPAdd, security.OutcomeSuccess, service) + } else if totpUpdated { + v.LogAudit(security.EventTOTPUpdate, security.OutcomeSuccess, service) + } + return nil } @@ -1267,6 +1345,34 @@ func (v *VaultService) GetUsageStats(service string) (map[string]UsageRecord, er return stats, nil } +// GetTOTPCode generates a TOTP code for the specified credential and logs the access +// Returns the code, remaining validity in seconds, and any error +func (v *VaultService) GetTOTPCode(service string) (string, int, error) { + if !v.unlocked { + return "", 0, ErrVaultLocked + } + + credential, exists := v.vaultData.Credentials[service] + if !exists { + return "", 0, fmt.Errorf("%w: %s", ErrCredentialNotFound, service) + } + + if !credential.HasTOTP() { + return "", 0, fmt.Errorf("no TOTP configured for credential: %s", service) + } + + code, remaining, err := credential.GetTOTPCode() + if err != nil { + v.LogAudit(security.EventTOTPAccess, security.OutcomeFailure, service) + return "", 0, err + } + + // Log TOTP access + v.LogAudit(security.EventTOTPAccess, security.OutcomeSuccess, service) + + return code, remaining, nil +} + // ChangePassword changes the vault master password // T012: Updated signature to accept []byte, T016: Added deferred cleanup // T046: Added password policy validation (FR-016)