Guardar contraseñas usando «Salted Password Hashing» y otras formas correctas

Escrito por el , actualizado el .
java programacion seguridad tapestry planeta-codigo
Enlace permanente Comentarios

Apache Shiro

Para cada servicio deberíamos emplear una contraseña de una longitud de al menos 8 caracteres que incluya letras en minúscula, mayúscula, números y símbolos, una herramienta que podemos utilizar para generar contraseñas más seguras con los criterios que indiquemos es Strong Password Generator o Password Generator.

Sin embargo, recordar cada una de estas contraseñas es muy difícil de modo que es habitual que utilicemos la misma contraseña para varios o todos los servicios y no empleando todos los criterios anteriores o usar una herramienta con la que Guardar contraseñas de forma segura con KeePassXC. Por otro lado, los desarrolladores no deberíamos guardar en la base de datos las contraseñas que nos entregan los usuarios en texto plano, para evitar guardalas en texto plano hace un tiempo se utilizaba únicamente una función de hashing unidireccional como MD5 o SHA, de este modo si la base de datos fuese comprometida en teoría no podrían conocer la contraseña original. En este artículo comentaré que aún guardando las contraseñas con una función de hashing no es suficiente para hacerlas seguras y comentaré una implementación con Apache Shiro de una de las ideas propuestas, también con Spring Security es posible.

Algo de teoría y algunas explicaciones

Aunque guardemos las contraseñas con MD5 o alguna variante de SHA hoy en día no es suficiente para que en caso de que alguien obtenga los hashes de las contraseñas de la base de datos pueda averiguarlas o dar con una que genere el mismo hash, usando estas funciones se pueden encontrar colisiones en un tiempo razonable y por tanto ya no se consideran seguras. Dada la computación actual de los procesadores y las tarjetas gráficas una contraseña débil puede romperse usando un ataque de fuerza bruta y quizá antes con un ataque de diccionario que pruebe las más comunes. Muchos usuarios no tienen contraseñas largas ni utilizan letras en minúscula, mayúscula, números y símbolos, muchos usuarios utilizan contraseñas sencillas para ser recordadas más fácilmente, y aún hasheando las contraseñas pueden ser averiguadas. También se pueden usar tablas arcoiris o rainbow tables con los hashes precalculados de las contraseñas de un diccionario con lo que el tiempo empleado para romper una puede requerir poco tiempo de computación.

También hay que tener en cuenta que muchos usuarios usan la misma contraseña para múltiples servicios por lo que basta que alguien obtenga la contraseña original de un servicio y podrá acceder a otros más interesantes para alguien con malas intenciones por mucha seguridad que tenga esos otros servicios, este es uno de los motivos de la autenticación en dos pasos (que emplea algo que sé, la contraseña, y algo que tengo, como el móvil) y la recomendación de usar una contraseña diferente para cada servicio. Las contraseñas por si solas tienen la seguridad más baja de los diferentes servicios donde se usen.

Con Salted Password Hashing el uso de rainbow tables que aceleren el ataque no serían posibles por la entropía añadida por los salt. Aún así conociendo el salt y la función de hash empleada seguiría siendo posible un ataque de fuerza bruta y de diccionario. Con Salted Password Hashing se usa en la función de hash y un dato variable denominado salt que añade suficiente entropía y es diferente para cada contraseña, en la base de datos se guarda el resultado de la función de hash junto con el salt, esto es, el resultado de SHA-512(contraseña + salt) y también el salt.

Java

Ejemplo de Salted Password Hashing usando Apache Shiro

Antes de comentar alguna opción más que dificulte los ataques de fuerza bruta o de diccionario veamos como implementar Salted Password Hashing empleando Apache Shiro como librería de autenticación y autorización para los usuarios. El ejemplo será simple sin guardar los datos en una base de datos pero suficiente para mostrar que se debe añadir al proyecto para que Shiro compruebe las contraseñas usando una función de hash y un salt. Partiré de un ejemplo que hice para el libro PlugIn Tapestry sobre el desarrollo de aplicaciones web con el framework Apache Tapestry. Básicamente deberemos crear un nuevo Realm que devuelva los datos del usuario, el hash y el salt. Una implementación suficiente para el ejemplo sería la siguiente, la parte importante está en el método doGetAuthenticationInfo, las clases SimpleAuthenticationInfo y HashedCredentialsMatcher y en la inicialización static de la clase:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
package io.github.picodotdev.plugintapestry.misc;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.shiro.authc.AccountException;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.cache.MemoryConstrainedCacheManager;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
import org.apache.shiro.crypto.hash.Sha512Hash;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.apache.shiro.util.SimpleByteSource;

public class Realm extends AuthorizingRealm {

    private static Map<String, Map<String, Object>> users;
    private static Map<String, Set<String>> permissions;
    
    // Para hacer más costoso el cálculo del hash y dificultar un ataque de fuerza bruta
    private static final int HASH_ITERATIONS = 5_000_000;

    static {
        // Generar una contraseña de clave «password», con SHA-512 y con «salt» aleatorio.
        ByteSource saltSource = new SecureRandomNumberGenerator().nextBytes();
        byte[] salt = saltSource.getBytes();
        Sha512Hash hash = new Sha512Hash("password", saltSource, HASH_ITERATIONS);
        String password = hash.toHex();
        // Contraseña codificada en Base64
        //String password = hash.toBase64();

        // Permissions (role, permissions)
        permissions = new HashMap<>();
        permissions.put("root", new HashSet<>(Arrays.asList(new String[] { "cuenta:reset" })));
        
        // Roles
        Set<String> roles = new HashSet<>();
        roles.add("root");

        // Usuario (property, value)
        Map<String, Object> user = new HashMap<>();
        user.put("username", "root");
        user.put("password", password);
        user.put("salt", salt);
        user.put("locked", Boolean.FALSE);
        user.put("expired", Boolean.FALSE);
        user.put("roles", roles);

        // Usuarios
        users = new HashMap<>();
        users.put("root", user);
    }

    public Realm() {
        super(new MemoryConstrainedCacheManager());

        HashedCredentialsMatcher cm = new HashedCredentialsMatcher(Sha512Hash.ALGORITHM_NAME);
        cm.setHashIterations(HASH_ITERATIONS);
        //cm.setStoredCredentialsHexEncoded(false);

        setName("local");
        setAuthenticationTokenClass(UsernamePasswordToken.class);
        setCredentialsMatcher(cm);
    }

    /**
     * Proporciona la autenticación de los usuarios.
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken atoken = (UsernamePasswordToken) token;

        String username = atoken.getUsername();

        if (username == null) { throw new AccountException("Null usernames are not allowed by this realm."); }

        Map<String, Object> user = findByUsername(username);
        if (user == null) {
            return null;
        }

        String password = (String) user.get("password");
        byte[] salt = (byte []) user.get("salt");
        boolean locked = (boolean) user.get("locked");
        boolean expired = (boolean) user.get("expired");

        if (locked) { throw new LockedAccountException("Account [" + username + "] is locked."); }
        if (expired) { throw new ExpiredCredentialsException("The credentials for account [" + username + "] are expired"); }

        return new SimpleAuthenticationInfo(username, password, new SimpleByteSource(salt), getName());
    }

    /**
     * Proporciona la autorización de los usuarios.
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        if (principals == null) { throw new AuthorizationException("PrincipalCollection was null, which should not happen"); }

        if (principals.isEmpty()) { return null; }

        if (principals.fromRealm(getName()).size() <= 0) { return null; }

        // Obtener el usuario
        String username = (String) principals.fromRealm(getName()).iterator().next();
        if (username == null) { return null; }
        Map<String, Object> user = findByUsername(username);
        if (user == null) { return null; }

        // Obtener los roles
        Set<String> roles = (Set<String>) user.get("roles");
        
        // Obtener los permisos de los roles
        Set<String> p = new HashSet<>();
        for (String role : roles) {
        	p.addAll((Set<String>) permissions.get(role));
        } 

        // Devolver el objeto de autorización
        SimpleAuthorizationInfo ai = new SimpleAuthorizationInfo();
        ai.setRoles(roles);
        ai.setStringPermissions(p);
        return ai;
    }

    private Map<String, Object> findByUsername(String username) {
        return users.get(username);
    }
}
Realm.java

Las contraseñas hasheadas tendrán la siguiente forma, podemos guardarlas codificadas en formato hexadecimal o en formato Base64:

1
2
Hex: 53a8b4b7eb9f5b8a0754916bcf2e11443149e8d0eb933624abf6feec4a8f43799bc177e0817a2a9df204d7c3597a379689f466f9b3bfe14b534c8d824ceeee22
Base64: U6i0t+ufW4oHVJFrzy4RRDFJ6NDrkzYkq/b+7EqPQ3mbwXfggXoqnfIE18NZejeWifRm+bO/4UtTTI2CTO7uIg==
hashed-password.txt

En el ejemplo tratándose de una aplicación web usando Apache Tapestry se debe modificar la configuración para que se utilice el nuevo Realm el antiguo guardaba las contraseñas en texto plano (shiro-users.properties).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
...
public static void contributeWebSecurityManager(Configuration<Realm> configuration) {
    // Realm básico
    //ExtendedPropertiesRealm realm = new ExtendedPropertiesRealm("classpath:shiro-users.properties");

    // Realm con «salted password hashing» y «salt»
    Realm realm = new es.com.blogspot.elblogdepicodev.plugintapestry.misc.Realm();

    configuration.add(realm);
}
...
AppModule.java

El cambio de Realm para el usuario no supone ninguna modificación y podrá seguir autenticandose con su misma contraseña. En el ejemplo con root como usuario y password como contraseña.

Formulario de inicio de sesión

Este es todo el código que necesitamos para la implementación de contraseñas codificadas con una función de hashing, en este caso SHA-512, y un salt, no es mucho y además es bastante simple la implementación con Shiro y en este caso en una aplicación usando el framework Apache Tapestry. Estas pocas líneas de código pueden aumentar notablemente la seguridad de las contraseñas que guardamos en la base de datos. En el caso de que la base de datos se vea comprometida será más difícil para alguien con malas intenciones obtener las contraseñas originales.

El siguiente ejemplo de federatedaccounts puede verse como usar está técnica de hash con salt usando una base de datos. Básicamente es lo mismo pero accediendo a base de datos para obtener el hash de la contraseña y el salt con una entidad JPA.

Otras opciones que añaden más seguridad

Aún así como comento este ejemplo de Salted Password Hashing aunque dificulta un ataque aún es viable usar fuerza bruta o un diccionario. En el artículo Password Security Right Way comentan tres ideas más. Una es usar como función de hash Bcrypt no porque sea más segura que SHA-512 sino porque es más lenta y esto puede hacer inviable la fuerza bruta o de diccionario, hay planes de proporcionar Bcrypt en Apache Shiro en futuras versiones. En el ejemplo como alternativa a bcrypt se usan varios millones de iteraciones de aplicación de la función para añadir tiempo de cálculo al hash, este tiempo adicional no es significativo en el cálculo de un hash pero en un ataque de fuerza bruta puede aumentarlo de forma tan significativa que sea inviable. La segunda idea interesante es además de hashear la clave es cifrarla de modo que aún habiendo sido comprometida la base de datos se necesite la clave privada de cifrado que también debería ser comprometida para producir el ataque. La tercera es partir el hash y distribuirlo entre varios sistemas de modo que sea necesario romperlos todos para obtener en hash original, lo que dificulta aún más un ataque.

Para implementar la segunda opción deberemos proporcionar implementaciones propias de CredentialsMatcher y de SimpleHash, quizá esto sea tema para otro artículo.

Código fuente del ejemplo

El código fuente completo del ejemplo está alojado en un repositorio de GitHub, es completamente funcional y puedes probarlo en tu equipo. Una vez descargado el siguiente comando e introduciendo en el navegador http://localhost:8080/PlugInTapestry, en la página que se muestra hay un botón para iniciar sesión:

1
2
$ ./gradlew run

gradlew.sh

Botón de inicio de sesión

Nota final

En este artículo recomiendo leer los interesantes enlaces del apartado de referencia del final, de ellos los siguientes dos son bastante completos Password Security the Right Way y The RIGHT Way: How to Hash Properly aunque todos merecen el tiempo dedicado a una lectura detenida. Para terminar mucho de esto es fútil si se permiten contraseñas sencillas por lo que exigir contraseñas con cierta fortaleza de la forma comentada al principio también es necesario si la seguridad de la aplicación es un requisito importante.


Comparte el artículo: