Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ toolchain go1.24.6
require (
github.com/klauspost/compress v1.18.0
github.com/manifoldco/promptui v0.9.0
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354
github.com/schollz/progressbar/v3 v3.18.0
github.com/zeebo/blake3 v0.2.4
golang.org/x/crypto v0.42.0
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/n
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
Expand Down Expand Up @@ -342,6 +344,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
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.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
Expand Down
6 changes: 4 additions & 2 deletions internal/encryption/passphrase/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/manifoldco/promptui"

"github.com/substantialcattle5/sietch/internal/config"
passphrasevalidation "github.com/substantialcattle5/sietch/internal/passphrase"
)

// promptForPassphrase prompts the user for a passphrase
Expand All @@ -19,8 +20,9 @@ func PromptForPassphrase(confirm bool) (string, error) {
Label: promptLabel,
Mask: '*',
Validate: func(input string) error {
if len(input) < 8 {
return fmt.Errorf("passphrase must be at least 8 characters")
result := passphrasevalidation.ValidateHybrid(input)
if !result.Valid || len(result.Warnings) > 0 {
return fmt.Errorf("%s", passphrasevalidation.GetHybridErrorMessage(result))
}
return nil
},
Expand Down
153 changes: 153 additions & 0 deletions internal/passphrase/hybrid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package passphrase

import (
"fmt"
"strings"
"unicode"

"github.com/nbutton23/zxcvbn-go"
)

// HybridValidationResult combines our strict rules with zxcvbn intelligence
type HybridValidationResult struct {
Valid bool
Errors []string
Warnings []string
Strength string
Score int
CrackTime string
IsCommon bool
}

// ValidateHybrid provides the best of both approaches:
// 1. Our strict character requirements (non-negotiable)
// 2. zxcvbn's intelligence for common password detection
func ValidateHybrid(passphrase string) HybridValidationResult {
result := HybridValidationResult{
Valid: true,
Errors: []string{},
Warnings: []string{},
}

// FIRST: Apply our strict requirements (non-negotiable)
basicResult := validateBasic(passphrase)
result.Valid = basicResult.Valid
result.Errors = append(result.Errors, basicResult.Errors...)

// SECOND: Only run expensive zxcvbn check if basic requirements are met
if result.Valid {
zxcvbnResult := zxcvbn.PasswordStrength(passphrase, nil)
result.Score = zxcvbnResult.Score
result.CrackTime = formatCrackTime(zxcvbnResult.CrackTime)

// Detect common/predictable passwords
if zxcvbnResult.Score <= 1 {
result.IsCommon = true
result.Warnings = append(result.Warnings,
"This passphrase is predictable or commonly used. Consider making it more unique.")
}

// Enhanced strength assessment based on zxcvbn score
if zxcvbnResult.Score >= 3 {
result.Strength = "Strong"
} else if zxcvbnResult.Score >= 2 {
result.Strength = "Good"
} else {
result.Strength = "Fair"
}
} else {
// If it doesn't pass basic validation, use our basic strength assessment
// No need to run expensive zxcvbn on invalid passwords
result.Strength = GetStrength(passphrase)
result.Score = 0 // Invalid passwords get minimum score
}

return result
}

// GetHybridErrorMessage returns user-friendly error message
func GetHybridErrorMessage(result HybridValidationResult) string {
if result.Valid && len(result.Warnings) == 0 {
return ""
}

var messages []string

// Critical errors first
messages = append(messages, result.Errors...)

// Then warnings
for _, warning := range result.Warnings {
messages = append(messages, "⚠️ "+warning)
}

if len(messages) == 1 {
return messages[0]
}

return fmt.Sprintf("Passphrase feedback:\n• %s", strings.Join(messages, "\n• "))
}

// validateBasic performs the basic character and length validation
func validateBasic(passphrase string) ValidationResult {
result := ValidationResult{
Valid: true,
Errors: []string{},
Warnings: []string{},
}

// Minimum length check
if len(passphrase) < 12 {
result.Valid = false
result.Errors = append(result.Errors, "passphrase must be at least 12 characters long")
}

// Character set requirements
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false

for _, char := range passphrase {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsDigit(char):
hasDigit = true
case isSpecialChar(char):
hasSpecial = true
}
}

if !hasUpper {
result.Valid = false
result.Errors = append(result.Errors, "passphrase must contain at least one uppercase letter")
}
if !hasLower {
result.Valid = false
result.Errors = append(result.Errors, "passphrase must contain at least one lowercase letter")
}
if !hasDigit {
result.Valid = false
result.Errors = append(result.Errors, "passphrase must contain at least one digit")
}
if !hasSpecial {
result.Valid = false
result.Errors = append(result.Errors, "passphrase must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
}

return result
}

// isSpecialChar checks if a character is a special character
func isSpecialChar(char rune) bool {
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
return strings.ContainsRune(specialChars, char)
}

// formatCrackTime formats the crack time display for user-friendly output
func formatCrackTime(crackTime interface{}) string {
if crackTime == nil {
return "unknown"
}
return fmt.Sprintf("%v", crackTime)
}
165 changes: 165 additions & 0 deletions internal/passphrase/passphrase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package passphrase

import (
"testing"
)

// Test basic validation functionality
func TestValidate(t *testing.T) {
tests := []struct {
name string
passphrase string
expectValid bool
}{
{"Valid passphrase", "ValidPassword123!", true},
{"Too short", "short", false},
{"Missing uppercase", "validpassword123!", false},
{"Missing lowercase", "VALIDPASSWORD123!", false},
{"Missing digit", "ValidPassword!", false},
{"Missing special", "ValidPassword123", false},
{"Valid minimum", "Password123!", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Validate(tt.passphrase)
if result.Valid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v", tt.expectValid, result.Valid)
}
})
}
}

// Test hybrid validation functionality
func TestValidateHybrid(t *testing.T) {
tests := []struct {
name string
passphrase string
expectValid bool
}{
{"Valid strong passphrase", "MyUniquePassword123!", true},
{"Too short", "short", false},
{"Missing requirements", "password", false},
{"Valid but common", "Password123!", true}, // May have warnings but still valid
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidateHybrid(tt.passphrase)
if result.Valid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v", tt.expectValid, result.Valid)
}
// Check that result has expected fields
if result.Strength == "" {
t.Error("Expected strength to be set")
}
})
}
}

// Test error message formatting
func TestGetErrorMessage(t *testing.T) {
result := ValidationResult{
Valid: false,
Errors: []string{"test error"},
}

msg := GetErrorMessage(result)
if msg == "" {
t.Error("Expected error message, got empty string")
}

// Test valid result
validResult := ValidationResult{Valid: true}
validMsg := GetErrorMessage(validResult)
if validMsg != "" {
t.Error("Expected empty message for valid result")
}
}

// Test hybrid error message formatting
func TestGetHybridErrorMessage(t *testing.T) {
result := HybridValidationResult{
Valid: false,
Errors: []string{"test error"},
}

msg := GetHybridErrorMessage(result)
if msg == "" {
t.Error("Expected error message, got empty string")
}

// Test valid result with no warnings
validResult := HybridValidationResult{Valid: true}
validMsg := GetHybridErrorMessage(validResult)
if validMsg != "" {
t.Error("Expected empty message for valid result with no warnings")
}
}

// Test strength assessment
func TestGetStrength(t *testing.T) {
tests := []struct {
name string
passphrase string
expected string
}{
{"Very weak", "abc", "Very Weak"},
{"Weak invalid", "password", "Weak"},
{"Strong valid", "StrongPassword123!", "Strong"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
strength := GetStrength(tt.passphrase)
if strength != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, strength)
}
})
}
}

// Test special character detection
func TestIsSpecialChar(t *testing.T) {
if !isSpecialChar('!') {
t.Error("Expected '!' to be special character")
}
if !isSpecialChar('@') {
t.Error("Expected '@' to be special character")
}
if isSpecialChar('a') {
t.Error("Expected 'a' to not be special character")
}
if isSpecialChar('1') {
t.Error("Expected '1' to not be special character")
}
}

// Test format crack time
func TestFormatCrackTime(t *testing.T) {
result := formatCrackTime(nil)
if result != "unknown" {
t.Errorf("Expected 'unknown' for nil, got %s", result)
}

result = formatCrackTime("2 hours")
if result != "2 hours" {
t.Errorf("Expected '2 hours', got %s", result)
}
}

// Test basic validation (internal function)
func TestValidateBasicFunction(t *testing.T) {
result := validateBasic("ValidPassword123!")
if !result.Valid {
t.Error("Expected valid result for good password")
}

result = validateBasic("short")
if result.Valid {
t.Error("Expected invalid result for short password")
}
if len(result.Errors) == 0 {
t.Error("Expected errors for invalid password")
}
}
Loading
Loading