- Go stdlib-only gateway: serves noVNC client, password auth, WS<->TCP relay - auth: PBKDF2-HMAC-SHA256 password hash, HMAC session cookies, login rate limit - relay: hand-written RFC 6455 WebSocket + transparent RFB bridge - vncspawner: cross-OS VNC server detection/launch (Windows/Linux/macOS) - server: /login /logout /vnc /api/status routes, session middleware, embed.FS - scripts: get-novnc, get-vnc (zip-verified), list-ips, open-firewall - run.bat: one-click launcher (asks only password), lists access IPs - README/AGENTS.md (Russian), .gitignore (root-anchored binaries)
240 lines
6.0 KiB
Go
240 lines
6.0 KiB
Go
// Package auth implements shared-password authentication using a salted
|
|
// PBKDF2-HMAC-SHA256 password hash, stateless HMAC-signed session cookies,
|
|
// and an in-memory login rate limiter. It depends only on the standard library.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
pbkdf2Iterations = 120_000
|
|
hashKeyLen = 32
|
|
hashScheme = "pbkdf2-sha256"
|
|
)
|
|
|
|
// Service handles authentication concerns for the gateway.
|
|
type Service struct {
|
|
passwordHash []byte // verified against the scheme string in parseHash
|
|
secret []byte
|
|
cookieName string
|
|
ttl time.Duration
|
|
limiter *rateLimiter
|
|
}
|
|
|
|
// New creates an auth Service.
|
|
func New(passwordHash string, secret []byte, cookieName string, ttl time.Duration) *Service {
|
|
return &Service{
|
|
passwordHash: []byte(passwordHash),
|
|
secret: secret,
|
|
cookieName: cookieName,
|
|
ttl: ttl,
|
|
limiter: newRateLimiter(5, time.Minute),
|
|
}
|
|
}
|
|
|
|
// CheckPassword verifies a plaintext password against the stored hash.
|
|
func (s *Service) CheckPassword(plain string) bool {
|
|
if len(s.passwordHash) == 0 {
|
|
return false
|
|
}
|
|
scheme, iter, salt, want, err := parseHash(string(s.passwordHash))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if scheme != hashScheme {
|
|
return false
|
|
}
|
|
got := pbkdf2Key([]byte(plain), salt, iter, hashKeyLen)
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
|
}
|
|
|
|
// HashPassword returns a self-describing salted hash for a plaintext password.
|
|
func HashPassword(plain string) (string, error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
key := pbkdf2Key([]byte(plain), salt, pbkdf2Iterations, hashKeyLen)
|
|
return hashScheme + "$" +
|
|
strconv.Itoa(pbkdf2Iterations) + "$" +
|
|
base64.RawStdEncoding.EncodeToString(salt) + "$" +
|
|
base64.RawStdEncoding.EncodeToString(key), nil
|
|
}
|
|
|
|
func parseHash(h string) (scheme string, iter int, salt, key []byte, err error) {
|
|
parts := strings.Split(h, "$")
|
|
if len(parts) != 4 {
|
|
return "", 0, nil, nil, errors.New("invalid hash format")
|
|
}
|
|
iter, err = strconv.Atoi(parts[1])
|
|
if err != nil || iter <= 0 {
|
|
return "", 0, nil, nil, errors.New("invalid iteration count")
|
|
}
|
|
salt, err = base64.RawStdEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return "", 0, nil, nil, err
|
|
}
|
|
key, err = base64.RawStdEncoding.DecodeString(parts[3])
|
|
if err != nil {
|
|
return "", 0, nil, nil, err
|
|
}
|
|
return parts[0], iter, salt, key, nil
|
|
}
|
|
|
|
// pbkdf2Key implements PBKDF2-HMAC-SHA256 (RFC 2898).
|
|
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
|
|
prf := hmac.New(sha256.New, password)
|
|
hLen := prf.Size()
|
|
numBlocks := (keyLen + hLen - 1) / hLen
|
|
|
|
out := make([]byte, 0, numBlocks*hLen)
|
|
var block [4]byte
|
|
for i := 1; i <= numBlocks; i++ {
|
|
prf.Reset()
|
|
prf.Write(salt)
|
|
binary.BigEndian.PutUint32(block[:], uint32(i))
|
|
prf.Write(block[:])
|
|
u := prf.Sum(nil)
|
|
t := make([]byte, len(u))
|
|
copy(t, u)
|
|
for j := 1; j < iter; j++ {
|
|
prf.Reset()
|
|
prf.Write(u)
|
|
u = prf.Sum(u[:0])
|
|
for k := range t {
|
|
t[k] ^= u[k]
|
|
}
|
|
}
|
|
out = append(out, t...)
|
|
}
|
|
return out[:keyLen]
|
|
}
|
|
|
|
// IssueSession creates a signed session token (cookie value).
|
|
// Format: <expUnix>.<base64url-hmac>.
|
|
func (s *Service) IssueSession(now time.Time) string {
|
|
exp := now.Add(s.ttl).Unix()
|
|
payload := strconv.FormatInt(exp, 10)
|
|
mac := s.computeMAC(payload)
|
|
return payload + "." + base64.RawURLEncoding.EncodeToString(mac)
|
|
}
|
|
|
|
// VerifySession validates a session token and returns true if valid & not expired.
|
|
func (s *Service) VerifySession(token string, now time.Time) bool {
|
|
parts := strings.SplitN(token, ".", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
payload := parts[0]
|
|
macStr := parts[1]
|
|
|
|
mac, err := base64.RawURLEncoding.DecodeString(macStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
expected := s.computeMAC(payload)
|
|
if !hmac.Equal(mac, expected) {
|
|
return false
|
|
}
|
|
exp, err := strconv.ParseInt(payload, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return exp > now.Unix()
|
|
}
|
|
|
|
func (s *Service) computeMAC(payload string) []byte {
|
|
m := hmac.New(sha256.New, s.secret)
|
|
m.Write([]byte(payload))
|
|
return m.Sum(nil)
|
|
}
|
|
|
|
// SetSessionCookie writes the session cookie on the response.
|
|
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.cookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: int(s.ttl.Seconds()),
|
|
})
|
|
}
|
|
|
|
// ClearSessionCookie expires the session cookie.
|
|
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: s.cookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
// SessionFromRequest extracts and validates the session cookie.
|
|
func (s *Service) SessionFromRequest(r *http.Request, now time.Time) bool {
|
|
c, err := r.Cookie(s.cookieName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return s.VerifySession(c.Value, now)
|
|
}
|
|
|
|
// AllowLogin enforces a per-IP rate limit on login attempts.
|
|
func (s *Service) AllowLogin(ip string) bool {
|
|
return s.limiter.allow(ip)
|
|
}
|
|
|
|
// CookieName returns the configured cookie name.
|
|
func (s *Service) CookieName() string { return s.cookieName }
|
|
|
|
// TTL returns the configured session TTL.
|
|
func (s *Service) TTL() time.Duration { return s.ttl }
|
|
|
|
// ---- rate limiter (fixed window per IP, in-memory) ----
|
|
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
max int
|
|
window time.Duration
|
|
hits map[string][]time.Time
|
|
}
|
|
|
|
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
|
return &rateLimiter{max: max, window: window, hits: make(map[string][]time.Time)}
|
|
}
|
|
|
|
func (r *rateLimiter) allow(ip string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
now := time.Now()
|
|
cutoff := now.Add(-r.window)
|
|
fresh := r.hits[ip][:0]
|
|
for _, t := range r.hits[ip] {
|
|
if t.After(cutoff) {
|
|
fresh = append(fresh, t)
|
|
}
|
|
}
|
|
if len(fresh) >= r.max {
|
|
r.hits[ip] = fresh
|
|
return false
|
|
}
|
|
fresh = append(fresh, now)
|
|
r.hits[ip] = fresh
|
|
return true
|
|
}
|