Skip to content

Commit 1b3142f

Browse files
authored
feat(validation): implement centralized passphrase validation system (#44)
* feat: implement centralized passphrase validation system Closes #38 Signed-off-by: Deepam02 <116721751+Deepam02@users.noreply.github.com> * fix: add zxcvbn-go dependency * fix lint issue * fix test failures: update passphrases to meet new validation requirements * Add tests for passphrase validation --------- Signed-off-by: Deepam02 <116721751+Deepam02@users.noreply.github.com>
1 parent 4f3a0ea commit 1b3142f

8 files changed

Lines changed: 477 additions & 20 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ toolchain go1.24.6
77
require (
88
github.com/klauspost/compress v1.18.0
99
github.com/manifoldco/promptui v0.9.0
10+
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354
1011
github.com/schollz/progressbar/v3 v3.18.0
1112
github.com/zeebo/blake3 v0.2.4
1213
golang.org/x/crypto v0.42.0

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/n
218218
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
219219
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
220220
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
221+
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 h1:4kuARK6Y6FxaNu/BnU2OAaLF86eTVhP2hjTB6iMvItA=
222+
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8=
221223
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
222224
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
223225
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
@@ -342,6 +344,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
342344
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
343345
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
344346
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
347+
github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
345348
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
346349
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
347350
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

internal/encryption/passphrase/prompt.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/manifoldco/promptui"
77

88
"github.com/substantialcattle5/sietch/internal/config"
9+
passphrasevalidation "github.com/substantialcattle5/sietch/internal/passphrase"
910
)
1011

1112
// promptForPassphrase prompts the user for a passphrase
@@ -19,8 +20,9 @@ func PromptForPassphrase(confirm bool) (string, error) {
1920
Label: promptLabel,
2021
Mask: '*',
2122
Validate: func(input string) error {
22-
if len(input) < 8 {
23-
return fmt.Errorf("passphrase must be at least 8 characters")
23+
result := passphrasevalidation.ValidateHybrid(input)
24+
if !result.Valid || len(result.Warnings) > 0 {
25+
return fmt.Errorf("%s", passphrasevalidation.GetHybridErrorMessage(result))
2426
}
2527
return nil
2628
},

internal/passphrase/hybrid.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package passphrase
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"unicode"
7+
8+
"github.com/nbutton23/zxcvbn-go"
9+
)
10+
11+
// HybridValidationResult combines our strict rules with zxcvbn intelligence
12+
type HybridValidationResult struct {
13+
Valid bool
14+
Errors []string
15+
Warnings []string
16+
Strength string
17+
Score int
18+
CrackTime string
19+
IsCommon bool
20+
}
21+
22+
// ValidateHybrid provides the best of both approaches:
23+
// 1. Our strict character requirements (non-negotiable)
24+
// 2. zxcvbn's intelligence for common password detection
25+
func ValidateHybrid(passphrase string) HybridValidationResult {
26+
result := HybridValidationResult{
27+
Valid: true,
28+
Errors: []string{},
29+
Warnings: []string{},
30+
}
31+
32+
// FIRST: Apply our strict requirements (non-negotiable)
33+
basicResult := validateBasic(passphrase)
34+
result.Valid = basicResult.Valid
35+
result.Errors = append(result.Errors, basicResult.Errors...)
36+
37+
// SECOND: Only run expensive zxcvbn check if basic requirements are met
38+
if result.Valid {
39+
zxcvbnResult := zxcvbn.PasswordStrength(passphrase, nil)
40+
result.Score = zxcvbnResult.Score
41+
result.CrackTime = formatCrackTime(zxcvbnResult.CrackTime)
42+
43+
// Detect common/predictable passwords
44+
if zxcvbnResult.Score <= 1 {
45+
result.IsCommon = true
46+
result.Warnings = append(result.Warnings,
47+
"This passphrase is predictable or commonly used. Consider making it more unique.")
48+
}
49+
50+
// Enhanced strength assessment based on zxcvbn score
51+
if zxcvbnResult.Score >= 3 {
52+
result.Strength = "Strong"
53+
} else if zxcvbnResult.Score >= 2 {
54+
result.Strength = "Good"
55+
} else {
56+
result.Strength = "Fair"
57+
}
58+
} else {
59+
// If it doesn't pass basic validation, use our basic strength assessment
60+
// No need to run expensive zxcvbn on invalid passwords
61+
result.Strength = GetStrength(passphrase)
62+
result.Score = 0 // Invalid passwords get minimum score
63+
}
64+
65+
return result
66+
}
67+
68+
// GetHybridErrorMessage returns user-friendly error message
69+
func GetHybridErrorMessage(result HybridValidationResult) string {
70+
if result.Valid && len(result.Warnings) == 0 {
71+
return ""
72+
}
73+
74+
var messages []string
75+
76+
// Critical errors first
77+
messages = append(messages, result.Errors...)
78+
79+
// Then warnings
80+
for _, warning := range result.Warnings {
81+
messages = append(messages, "⚠️ "+warning)
82+
}
83+
84+
if len(messages) == 1 {
85+
return messages[0]
86+
}
87+
88+
return fmt.Sprintf("Passphrase feedback:\n• %s", strings.Join(messages, "\n• "))
89+
}
90+
91+
// validateBasic performs the basic character and length validation
92+
func validateBasic(passphrase string) ValidationResult {
93+
result := ValidationResult{
94+
Valid: true,
95+
Errors: []string{},
96+
Warnings: []string{},
97+
}
98+
99+
// Minimum length check
100+
if len(passphrase) < 12 {
101+
result.Valid = false
102+
result.Errors = append(result.Errors, "passphrase must be at least 12 characters long")
103+
}
104+
105+
// Character set requirements
106+
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false
107+
108+
for _, char := range passphrase {
109+
switch {
110+
case unicode.IsUpper(char):
111+
hasUpper = true
112+
case unicode.IsLower(char):
113+
hasLower = true
114+
case unicode.IsDigit(char):
115+
hasDigit = true
116+
case isSpecialChar(char):
117+
hasSpecial = true
118+
}
119+
}
120+
121+
if !hasUpper {
122+
result.Valid = false
123+
result.Errors = append(result.Errors, "passphrase must contain at least one uppercase letter")
124+
}
125+
if !hasLower {
126+
result.Valid = false
127+
result.Errors = append(result.Errors, "passphrase must contain at least one lowercase letter")
128+
}
129+
if !hasDigit {
130+
result.Valid = false
131+
result.Errors = append(result.Errors, "passphrase must contain at least one digit")
132+
}
133+
if !hasSpecial {
134+
result.Valid = false
135+
result.Errors = append(result.Errors, "passphrase must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
136+
}
137+
138+
return result
139+
}
140+
141+
// isSpecialChar checks if a character is a special character
142+
func isSpecialChar(char rune) bool {
143+
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
144+
return strings.ContainsRune(specialChars, char)
145+
}
146+
147+
// formatCrackTime formats the crack time display for user-friendly output
148+
func formatCrackTime(crackTime interface{}) string {
149+
if crackTime == nil {
150+
return "unknown"
151+
}
152+
return fmt.Sprintf("%v", crackTime)
153+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package passphrase
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// Test basic validation functionality
8+
func TestValidate(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
passphrase string
12+
expectValid bool
13+
}{
14+
{"Valid passphrase", "ValidPassword123!", true},
15+
{"Too short", "short", false},
16+
{"Missing uppercase", "validpassword123!", false},
17+
{"Missing lowercase", "VALIDPASSWORD123!", false},
18+
{"Missing digit", "ValidPassword!", false},
19+
{"Missing special", "ValidPassword123", false},
20+
{"Valid minimum", "Password123!", true},
21+
}
22+
23+
for _, tt := range tests {
24+
t.Run(tt.name, func(t *testing.T) {
25+
result := Validate(tt.passphrase)
26+
if result.Valid != tt.expectValid {
27+
t.Errorf("Expected valid=%v, got %v", tt.expectValid, result.Valid)
28+
}
29+
})
30+
}
31+
}
32+
33+
// Test hybrid validation functionality
34+
func TestValidateHybrid(t *testing.T) {
35+
tests := []struct {
36+
name string
37+
passphrase string
38+
expectValid bool
39+
}{
40+
{"Valid strong passphrase", "MyUniquePassword123!", true},
41+
{"Too short", "short", false},
42+
{"Missing requirements", "password", false},
43+
{"Valid but common", "Password123!", true}, // May have warnings but still valid
44+
}
45+
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
result := ValidateHybrid(tt.passphrase)
49+
if result.Valid != tt.expectValid {
50+
t.Errorf("Expected valid=%v, got %v", tt.expectValid, result.Valid)
51+
}
52+
// Check that result has expected fields
53+
if result.Strength == "" {
54+
t.Error("Expected strength to be set")
55+
}
56+
})
57+
}
58+
}
59+
60+
// Test error message formatting
61+
func TestGetErrorMessage(t *testing.T) {
62+
result := ValidationResult{
63+
Valid: false,
64+
Errors: []string{"test error"},
65+
}
66+
67+
msg := GetErrorMessage(result)
68+
if msg == "" {
69+
t.Error("Expected error message, got empty string")
70+
}
71+
72+
// Test valid result
73+
validResult := ValidationResult{Valid: true}
74+
validMsg := GetErrorMessage(validResult)
75+
if validMsg != "" {
76+
t.Error("Expected empty message for valid result")
77+
}
78+
}
79+
80+
// Test hybrid error message formatting
81+
func TestGetHybridErrorMessage(t *testing.T) {
82+
result := HybridValidationResult{
83+
Valid: false,
84+
Errors: []string{"test error"},
85+
}
86+
87+
msg := GetHybridErrorMessage(result)
88+
if msg == "" {
89+
t.Error("Expected error message, got empty string")
90+
}
91+
92+
// Test valid result with no warnings
93+
validResult := HybridValidationResult{Valid: true}
94+
validMsg := GetHybridErrorMessage(validResult)
95+
if validMsg != "" {
96+
t.Error("Expected empty message for valid result with no warnings")
97+
}
98+
}
99+
100+
// Test strength assessment
101+
func TestGetStrength(t *testing.T) {
102+
tests := []struct {
103+
name string
104+
passphrase string
105+
expected string
106+
}{
107+
{"Very weak", "abc", "Very Weak"},
108+
{"Weak invalid", "password", "Weak"},
109+
{"Strong valid", "StrongPassword123!", "Strong"},
110+
}
111+
112+
for _, tt := range tests {
113+
t.Run(tt.name, func(t *testing.T) {
114+
strength := GetStrength(tt.passphrase)
115+
if strength != tt.expected {
116+
t.Errorf("Expected %s, got %s", tt.expected, strength)
117+
}
118+
})
119+
}
120+
}
121+
122+
// Test special character detection
123+
func TestIsSpecialChar(t *testing.T) {
124+
if !isSpecialChar('!') {
125+
t.Error("Expected '!' to be special character")
126+
}
127+
if !isSpecialChar('@') {
128+
t.Error("Expected '@' to be special character")
129+
}
130+
if isSpecialChar('a') {
131+
t.Error("Expected 'a' to not be special character")
132+
}
133+
if isSpecialChar('1') {
134+
t.Error("Expected '1' to not be special character")
135+
}
136+
}
137+
138+
// Test format crack time
139+
func TestFormatCrackTime(t *testing.T) {
140+
result := formatCrackTime(nil)
141+
if result != "unknown" {
142+
t.Errorf("Expected 'unknown' for nil, got %s", result)
143+
}
144+
145+
result = formatCrackTime("2 hours")
146+
if result != "2 hours" {
147+
t.Errorf("Expected '2 hours', got %s", result)
148+
}
149+
}
150+
151+
// Test basic validation (internal function)
152+
func TestValidateBasicFunction(t *testing.T) {
153+
result := validateBasic("ValidPassword123!")
154+
if !result.Valid {
155+
t.Error("Expected valid result for good password")
156+
}
157+
158+
result = validateBasic("short")
159+
if result.Valid {
160+
t.Error("Expected invalid result for short password")
161+
}
162+
if len(result.Errors) == 0 {
163+
t.Error("Expected errors for invalid password")
164+
}
165+
}

0 commit comments

Comments
 (0)