package main import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/hex" "fmt" "os" "strings" "sync" "time" "unicode" "golang.org/x/crypto/argon2" ) // Parametros de argon2id. 64 MiB por intento es suficiente para que probar // claves a lo bruto salga caro y poco para el servidor, que atiende a treinta // cuentas. const ( argonMemoria = 64 * 1024 argonIteraciones = 3 argonHilos = 4 argonLargo = 32 argonSal = 16 ) // argonHuecos limita cuántos cálculos de argon2 corren a la vez. Cada uno // reserva argonMemoria (64 MiB): sin este tope, sesenta intentos de acceso // simultáneos llevaban el proceso a casi 4 GB y podían tumbar la máquina, que // comparte con gitea y el resto de servicios de la Enre. Los que llegan de más // esperan su turno en vez de reservar memoria. var argonHuecos = make(chan struct{}, 2) func argonID(clave, sal []byte, t, m uint32, p uint8, largo uint32) []byte { argonHuecos <- struct{}{} defer func() { <-argonHuecos }() return argon2.IDKey(clave, sal, t, m, p, largo) } func aleatorio(n int) []byte { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic("sin fuente de aleatoriedad: " + err.Error()) } return b } func nuevoID() string { return hex.EncodeToString(aleatorio(16)) } // HashClave devuelve la clave cifrada en el formato codificado de argon2, que // lleva dentro los parametros con los que se genero. func HashClave(clave string) string { sal := aleatorio(argonSal) h := argonID([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemoria, argonIteraciones, argonHilos, base64.RawStdEncoding.EncodeToString(sal), base64.RawStdEncoding.EncodeToString(h)) } // hashInutil se usa para gastar el mismo tiempo cuando la cuenta no existe, de // forma que no se pueda averiguar que usuarios hay midiendo la respuesta. var hashInutil = HashClave("cuenta que no existe") func VerificarClave(codificado, clave string) bool { partes := strings.Split(codificado, "$") if len(partes) != 6 || partes[1] != "argon2id" { return false } var version, memoria, iteraciones int var hilos int if _, err := fmt.Sscanf(partes[2], "v=%d", &version); err != nil { return false } if _, err := fmt.Sscanf(partes[3], "m=%d,t=%d,p=%d", &memoria, &iteraciones, &hilos); err != nil { return false } sal, err := base64.RawStdEncoding.DecodeString(partes[4]) if err != nil { return false } esperado, err := base64.RawStdEncoding.DecodeString(partes[5]) if err != nil { return false } // Los parámetros salen del propio hash, que escribimos nosotros; aun así se // acotan, para que un registro manipulado no pida gigas de memoria. if memoria <= 0 || memoria > argonMemoria || iteraciones <= 0 || iteraciones > 10 || hilos <= 0 || hilos > 16 || len(esperado) == 0 || len(esperado) > 64 { return false } calculado := argonID([]byte(clave), sal, uint32(iteraciones), uint32(memoria), uint8(hilos), uint32(len(esperado))) return subtle.ConstantTimeCompare(calculado, esperado) == 1 } // ValidarClave es la politica de contrasenas: larga antes que retorcida, que es // lo que de verdad aguanta un ataque por diccionario. func ValidarClave(c string) error { if len([]rune(c)) < 12 { return fmt.Errorf("la contraseña tiene que tener al menos 12 caracteres") } if len(c) > 256 { return fmt.Errorf("la contraseña no puede pasar de 256 caracteres") } distintos := map[rune]bool{} for _, r := range c { distintos[r] = true } if len(distintos) < 5 { return fmt.Errorf("la contraseña repite demasiado los mismos caracteres") } return nil } // ClaveTemporal genera la clave de un solo uso que se entrega en mano al dar de // alta un colectivo. Sin caracteres que se confundan al copiarla a papel. func ClaveTemporal() string { const alfabeto = "abcdefghjkmnpqrstuvwxyz23456789" b := aleatorio(20) var sb strings.Builder for i, v := range b { if i > 0 && i%5 == 0 { sb.WriteByte('-') } sb.WriteByte(alfabeto[int(v)%len(alfabeto)]) } return sb.String() } func ValidarUsuario(u string) error { if len(u) < 3 || len(u) > 32 { return fmt.Errorf("el usuario tiene entre 3 y 32 caracteres") } for _, r := range u { if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '_' && r != '-' { return fmt.Errorf("el usuario solo admite minúsculas, números, guion y guion bajo") } } return nil } // Sesiones viven solo en memoria: se pueden revocar al instante y un reinicio // del servicio cierra todas las sesiones abiertas, que es lo que interesa. type Sesiones struct { mu sync.Mutex m map[string]*Sesion maxInactivo time.Duration } func NuevasSesiones(maxInactivo time.Duration) *Sesiones { return &Sesiones{m: map[string]*Sesion{}, maxInactivo: maxInactivo} } func (s *Sesiones) Crear(usuario string) *Sesion { s.mu.Lock() defer s.mu.Unlock() ses := &Sesion{ ID: hex.EncodeToString(aleatorio(32)), Usuario: usuario, CSRF: hex.EncodeToString(aleatorio(32)), Creada: time.Now(), Vista: time.Now(), } s.m[ses.ID] = ses return ses } func (s *Sesiones) Buscar(id string) (*Sesion, bool) { s.mu.Lock() defer s.mu.Unlock() ses, ok := s.m[id] if !ok { return nil, false } if time.Since(ses.Vista) > s.maxInactivo { delete(s.m, id) return nil, false } ses.Vista = time.Now() return ses, true } // PonerClave guarda la clave temporal recien generada para ensenarla una sola // vez en la siguiente pagina, sin que pase por la URL. func (s *Sesiones) PonerClave(id, usuario, clave string) { s.mu.Lock() defer s.mu.Unlock() if ses, ok := s.m[id]; ok { ses.ClaveDe, ses.ClaveNueva = usuario, clave } } // TomarClave la devuelve y la borra: no se vuelve a mostrar. func (s *Sesiones) TomarClave(id string) (string, string) { s.mu.Lock() defer s.mu.Unlock() ses, ok := s.m[id] if !ok { return "", "" } usuario, clave := ses.ClaveDe, ses.ClaveNueva ses.ClaveDe, ses.ClaveNueva = "", "" return usuario, clave } func (s *Sesiones) PonerAviso(id, aviso, fallo string) { s.mu.Lock() defer s.mu.Unlock() if ses, ok := s.m[id]; ok { ses.Aviso, ses.Error = aviso, fallo } } // TomarAviso devuelve el aviso pendiente y lo borra: se enseña una vez. func (s *Sesiones) TomarAviso(id string) (string, string) { s.mu.Lock() defer s.mu.Unlock() ses, ok := s.m[id] if !ok { return "", "" } aviso, fallo := ses.Aviso, ses.Error ses.Aviso, ses.Error = "", "" return aviso, fallo } func (s *Sesiones) Borrar(id string) { s.mu.Lock() defer s.mu.Unlock() delete(s.m, id) } // BorrarDeUsuario cierra todas las sesiones de una cuenta: al cambiarle la // clave o al desactivarla. func (s *Sesiones) BorrarDeUsuario(usuario string) { s.mu.Lock() defer s.mu.Unlock() for id, ses := range s.m { if ses.Usuario == usuario { delete(s.m, id) } } } func (s *Sesiones) Limpiar() { s.mu.Lock() defer s.mu.Unlock() for id, ses := range s.m { if time.Since(ses.Vista) > s.maxInactivo { delete(s.m, id) } } } // Cerrojo cuenta intentos fallidos por IP y por cuenta. Es la primera barrera, // dentro de la propia aplicacion; fail2ban es la segunda, en el cortafuegos. type Cerrojo struct { mu sync.Mutex fallos map[string][]time.Time maxIntentos int ventana time.Duration espera time.Duration } func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo { return &Cerrojo{ fallos: map[string][]time.Time{}, maxIntentos: maxIntentos, ventana: ventana, espera: espera, } } // Intentar comprueba y apunta el intento en la misma operación, antes de // calcular la clave. Si se apuntara después, como hacía Fallo, cien intentos // simultáneos pasaban todos la comprobación antes de que se apuntara el // primero. func (c *Cerrojo) Intentar(clave string) (bool, time.Duration) { c.mu.Lock() defer c.mu.Unlock() ahora := time.Now() recientes := c.podar(clave, ahora) if len(recientes) >= c.maxIntentos { if falta := c.espera - ahora.Sub(recientes[len(recientes)-1]); falta > 0 { return false, falta } } c.fallos[clave] = append(recientes, ahora) return true, 0 } func (c *Cerrojo) Limpiar(claves ...string) { c.mu.Lock() defer c.mu.Unlock() for _, k := range claves { delete(c.fallos, k) } } func (c *Cerrojo) podar(k string, ahora time.Time) []time.Time { var out []time.Time for _, t := range c.fallos[k] { if ahora.Sub(t) <= c.ventana { out = append(out, t) } } if len(out) == 0 { delete(c.fallos, k) } else { c.fallos[k] = out } return out } // AuthLog escribe el registro que lee fail2ban. Formato fijo y de una sola // linea por intento. type AuthLog struct { mu sync.Mutex f *os.File } func AbrirAuthLog(ruta string) (*AuthLog, error) { if ruta == "" { return &AuthLog{}, nil } f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) if err != nil { return nil, err } return &AuthLog{f: f}, nil } // limpiarCampo evita que un usuario inventado meta saltos de linea o espacios // en el log: si no, se podria falsificar una linea AUTH_FAIL con la IP de otro // y hacer que fail2ban banee a quien no toca. func limpiarCampo(s string) string { if s == "" { return "-" } var sb strings.Builder for _, r := range s { if r > unicode.MaxASCII || unicode.IsSpace(r) || !unicode.IsPrint(r) { sb.WriteByte('.') continue } if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' { sb.WriteRune(r) } else { sb.WriteByte('.') } if sb.Len() >= 32 { break } } if sb.Len() == 0 { return "-" } return sb.String() } func (l *AuthLog) Escribir(evento, ip, usuario, motivo string) { linea := fmt.Sprintf("%s %s ip=%s usuario=%s motivo=%s\n", time.Now().Format(time.RFC3339), evento, limpiarCampo(ip), limpiarCampo(usuario), limpiarCampo(motivo)) l.mu.Lock() defer l.mu.Unlock() if l.f != nil { l.f.WriteString(linea) return } os.Stderr.WriteString(linea) } // iguales compara dos cadenas sin filtrar por el tiempo cuanto coinciden. func iguales(a, b string) bool { return len(a) == len(b) && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } func (l *AuthLog) Cerrar() { l.mu.Lock() defer l.mu.Unlock() if l.f != nil { l.f.Close() } }