feat: implement SSH honeypot server with auth and config
Add core SSH server with password authentication, per-IP failure tracking, credential memory with TTL, and static credential support. Includes TOML config loading with validation, Ed25519 host key auto-generation, and a Nix package output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
68
internal/auth/auth.go
Normal file
68
internal/auth/auth.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
)
|
||||
|
||||
type credKey struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Accepted bool
|
||||
Reason string // "static_credential", "threshold_reached", "remembered_credential", "rejected"
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
mu sync.Mutex
|
||||
cfg config.AuthConfig
|
||||
failCounts map[string]int // IP -> consecutive failures
|
||||
rememberedCreds map[credKey]time.Time // (user,pass) -> expiry
|
||||
now func() time.Time // for testing
|
||||
}
|
||||
|
||||
func NewAuthenticator(cfg config.AuthConfig) *Authenticator {
|
||||
return &Authenticator{
|
||||
cfg: cfg,
|
||||
failCounts: make(map[string]int),
|
||||
rememberedCreds: make(map[credKey]time.Time),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(ip, username, password string) Decision {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// 1. Check static credentials.
|
||||
for _, cred := range a.cfg.StaticCredentials {
|
||||
if cred.Username == username && cred.Password == password {
|
||||
a.failCounts[ip] = 0
|
||||
return Decision{Accepted: true, Reason: "static_credential"}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check remembered credentials.
|
||||
key := credKey{Username: username, Password: password}
|
||||
if expiry, ok := a.rememberedCreds[key]; ok {
|
||||
if a.now().Before(expiry) {
|
||||
a.failCounts[ip] = 0
|
||||
return Decision{Accepted: true, Reason: "remembered_credential"}
|
||||
}
|
||||
delete(a.rememberedCreds, key)
|
||||
}
|
||||
|
||||
// 3. Increment fail count, check threshold.
|
||||
a.failCounts[ip]++
|
||||
if a.failCounts[ip] >= a.cfg.AcceptAfter {
|
||||
a.failCounts[ip] = 0
|
||||
a.rememberedCreds[key] = a.now().Add(a.cfg.CredentialTTLDuration)
|
||||
return Decision{Accepted: true, Reason: "threshold_reached"}
|
||||
}
|
||||
|
||||
return Decision{Accepted: false, Reason: "rejected"}
|
||||
}
|
||||
142
internal/auth/auth_test.go
Normal file
142
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||
)
|
||||
|
||||
func newTestAuth(acceptAfter int, ttl time.Duration, statics ...config.Credential) *Authenticator {
|
||||
a := NewAuthenticator(config.AuthConfig{
|
||||
AcceptAfter: acceptAfter,
|
||||
CredentialTTLDuration: ttl,
|
||||
StaticCredentials: statics,
|
||||
})
|
||||
return a
|
||||
}
|
||||
|
||||
func TestStaticCredentialsAccepted(t *testing.T) {
|
||||
a := newTestAuth(10, time.Hour, config.Credential{Username: "root", Password: "toor"})
|
||||
d := a.Authenticate("1.2.3.4", "root", "toor")
|
||||
if !d.Accepted || d.Reason != "static_credential" {
|
||||
t.Errorf("got %+v, want accepted with static_credential", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticCredentialsWrongPassword(t *testing.T) {
|
||||
a := newTestAuth(10, time.Hour, config.Credential{Username: "root", Password: "toor"})
|
||||
d := a.Authenticate("1.2.3.4", "root", "wrong")
|
||||
if d.Accepted {
|
||||
t.Errorf("should not accept wrong password for static credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectionBeforeThreshold(t *testing.T) {
|
||||
a := newTestAuth(3, time.Hour)
|
||||
for i := 0; i < 2; i++ {
|
||||
d := a.Authenticate("1.2.3.4", "user", "pass")
|
||||
if d.Accepted {
|
||||
t.Fatalf("attempt %d should be rejected", i+1)
|
||||
}
|
||||
if d.Reason != "rejected" {
|
||||
t.Errorf("attempt %d reason = %q, want %q", i+1, d.Reason, "rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThresholdAcceptance(t *testing.T) {
|
||||
a := newTestAuth(3, time.Hour)
|
||||
for i := 0; i < 2; i++ {
|
||||
d := a.Authenticate("1.2.3.4", "user", "pass")
|
||||
if d.Accepted {
|
||||
t.Fatalf("attempt %d should be rejected", i+1)
|
||||
}
|
||||
}
|
||||
d := a.Authenticate("1.2.3.4", "user", "pass")
|
||||
if !d.Accepted || d.Reason != "threshold_reached" {
|
||||
t.Errorf("attempt 3 got %+v, want accepted with threshold_reached", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerIPIsolation(t *testing.T) {
|
||||
a := newTestAuth(3, time.Hour)
|
||||
|
||||
// IP1 gets 2 failures.
|
||||
for i := 0; i < 2; i++ {
|
||||
a.Authenticate("1.1.1.1", "user", "pass")
|
||||
}
|
||||
|
||||
// IP2 should start at 0, not inherit IP1's count.
|
||||
d := a.Authenticate("2.2.2.2", "user", "pass")
|
||||
if d.Accepted {
|
||||
t.Error("IP2's first attempt should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialMemoryAcrossIPs(t *testing.T) {
|
||||
a := newTestAuth(2, time.Hour)
|
||||
|
||||
// IP1 reaches threshold, credential is remembered.
|
||||
a.Authenticate("1.1.1.1", "user", "pass")
|
||||
d := a.Authenticate("1.1.1.1", "user", "pass")
|
||||
if !d.Accepted || d.Reason != "threshold_reached" {
|
||||
t.Fatalf("threshold not reached: %+v", d)
|
||||
}
|
||||
|
||||
// IP2 should get in with the remembered credential.
|
||||
d = a.Authenticate("2.2.2.2", "user", "pass")
|
||||
if !d.Accepted || d.Reason != "remembered_credential" {
|
||||
t.Errorf("IP2 got %+v, want accepted with remembered_credential", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialMemoryExpires(t *testing.T) {
|
||||
a := newTestAuth(2, time.Hour)
|
||||
now := time.Now()
|
||||
a.now = func() time.Time { return now }
|
||||
|
||||
// Reach threshold to remember credential.
|
||||
a.Authenticate("1.1.1.1", "user", "pass")
|
||||
a.Authenticate("1.1.1.1", "user", "pass")
|
||||
|
||||
// Advance past TTL.
|
||||
a.now = func() time.Time { return now.Add(2 * time.Hour) }
|
||||
|
||||
d := a.Authenticate("2.2.2.2", "user", "pass")
|
||||
if d.Accepted {
|
||||
t.Errorf("expired credential should not be accepted: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCounterResetsAfterAcceptance(t *testing.T) {
|
||||
a := newTestAuth(2, time.Hour)
|
||||
|
||||
// Reach threshold.
|
||||
a.Authenticate("1.1.1.1", "user", "pass")
|
||||
d := a.Authenticate("1.1.1.1", "user", "pass")
|
||||
if !d.Accepted {
|
||||
t.Fatal("threshold not reached")
|
||||
}
|
||||
|
||||
// With a different credential, counter should be reset.
|
||||
d = a.Authenticate("1.1.1.1", "other", "cred")
|
||||
if d.Accepted {
|
||||
t.Error("first attempt after reset should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
a := newTestAuth(5, time.Hour)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
a.Authenticate("1.2.3.4", "user", "pass")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user