由于密码被泄露的责任,存储密码可能是一个细微差别。更糟糕的是,用户倾向于跨服务重复使用密码,这使得安全地存储密码变得更加重要。

安全存储密码的目的是,即使包含密码的数据库遭到破坏,攻击者也无法破译任何用户的实际密码。这排除了将密码存储在纯文本中。

使用加密似乎是一个不错的选择,因为攻击者不知道实际的密码(因为它们是加密的)。但是,如果数据库被泄露,那么加密密钥可能[^1] 也会被泄露。使用这些密钥,攻击者将能够解密加密的密码——这使得这种存储方法很弱。

这就是散列或散列函数发挥作用的地方。

什么是哈希函数?

它们是具有以下属性的函数:

OutInhash(In) = Out"401357cf18542b4117ca59800657b64cce2a36d8ad4c56b6102a1e0b03049e97"string"hello""2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824""hella""70de66401b1399d79b843521ee726dcec1e9a8cb5708ec1520f1f3bb4b1dd984"

总而言之,哈希函数是“单向函数”。如果你只知道输出,就不可能/很难知道它的输入。这与加密不同,在给定输出和加密密钥的情况下,您可以知道输入。

由于这种单向属性,存储密码的散列值是一个好主意,因为如果它们的散列被泄露(通过数据库泄漏),攻击者将不知道原始密码(这是散列函数的输入) .事实上,唯一知道散列函数输入的“实体”是最初生成密码的最终用户。从安全的角度来看,这正是我们想要的。

由于属性编号 (5),有无限数量的输入可以产生相同的哈希输出。但是,在给定特定输出的情况下,也很难找到这些无限数量的输入中的任何一个!

什么是加盐以及为什么单独散列还不够好 - 人类的问题

很多人倾向于使用常用密码比如“密码”、“12345”等。由于相同输入的哈希值永远不会改变(见上面的属性(4)),我们可以预先计算常用密码的哈希值,然后检查针对这些预先计算的哈希泄露数据库数据。

"12345""5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5""12345"

解决这个问题的方法是在对密码进行散列之前(在注册过程中)添加一些随机字符串,称为“盐”,然后我们将该随机字符串附加到计算的散列中,然后再将其存储到数据库中.举个例子:

"12345""12345""ab$45""ih&g3""12345ab$45""12345ih&g3""2bb12bb768eb669f0e4b9df29e22a00467eb513c275ccfff1013288facac7889""b63400702c6f012aeaa57b5dc7eefaaaf3207cc6b68917911c410015ac0659b2"
"12345ab$45""12345ih&g3"

在我们将这些哈希值存储在数据库中之前,我们必须将盐附加到它们:

"2bb12bb768eb669f0e4b9df29e22a00467eb513c275ccfff1013288facac7889.ab$45""b63400702c6f012aeaa57b5dc7eefaaaf3207cc6b68917911c410015ac0659b2.ih&g3"

我们将盐附加到散列的原因是,在验证过程中,我们必须使用与最初相同的盐。所以我们必须把它存储在某个地方。即使盐被泄露,这也不是安全问题,因为攻击者仍然需要知道/猜测用户的密码才能生成相同的哈希。

让我们看看验证过程是如何发生的:

"abcdef""ab$45""abcdefab$45""c5110931a3ae4762c1c0334d8eeba8c9c555962cf7d2750fdd732936319a058c""c5110931a3ae4762c1c0334d8eeba8c9c555962cf7d2750fdd732936319a058c.ab$45"
"12345"

选择哪个哈希函数?

即使在加盐之后,暴力攻击的问题仍然存在。攻击者可以反复猜测不同的密码(非常快速),以查看哪个密码与泄露的哈希值匹配。有两个维度决定了攻击者可以多快找到匹配项:

1、用户密码的随机性和长度。

  1. 哈希函数计算哈希所需的时间

如果用户使用随机且足够长的密码,攻击者猜测该确切字符串的机会就会减少。这意味着他们必须进行更多的猜测,这将需要更多的时间。这是一个非常酷的工具,它估计猜测给定密码需要多长时间。

散列函数越慢且计算成本越高,验证每个猜测所需的时间就越多。在撰写本文时(2022 年 3 月 2 日),推荐的散列技术是使用Argon2id,最小配置为 15 MiB 内存,迭代次数为 2,并行度为 1[3].

随着计算能力的提高,推荐的散列技术也会发生变化。即使算法保持不变,建议的“轮数”/每个密码哈希应该完成的“工作量”可能会增加。

示例代码

NodeJS

import * as argon2 from "argon2";
import * as crypto from "crypto";

const hashingConfig = { // based on OWASP cheat sheet recommendations (as of March, 2022)
    parallelism: 1,
    memoryCost: 64000, // 64 mb
    timeCost: 3 // number of itetations
}

async function hashPassword(password: string) {
    let salt = crypto.randomBytes(16);
    return await argon2.hash(password, {
        ...hashingConfig,
        salt,
    })
}

async function verifyPasswordWithHash(password: string, hash: string) {
    return await argon2.verify(hash, password, hashingConfig);
}

hashPassword("somePassword").then(async (hash) => {
    console.log("Hash + salt of the password:", hash)
    console.log("Password verification success:", await verifyPasswordWithHash("somePassword", hash));
});

以上产生以下输出:

Hash + salt of the password: $argon2i$v=19$m=15000,t=3,p=1$tgSmiYOCjQ0im5U6NXEvPg$xKC4V31JqIK2XO91fnMCfevATq1rVDjIRX0cf/dnbKY

Password verification success: true

如果你运行上面的程序,每次都会产生不同的哈希,因为每次都会重新生成盐。

Go语言

package main

import (
    "crypto/rand"
    "crypto/subtle"
    "encoding/base64"
    "errors"
    "fmt"
    "log"
    "strings"

    "golang.org/x/crypto/argon2"
)

type params struct {
    memory      uint32
    iterations  uint32
    parallelism uint8
    saltLength  uint32
    keyLength   uint32
}

func main() {
    p := &params{
        memory:      64 * 1024, // 64 MB
        iterations:  3,
        parallelism: 1,
        saltLength:  16,
        keyLength:   32,
    }

    encodedHash, err := generateHashFromPassword("somePassword", p)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Hash + salt of the password:")
    fmt.Println(encodedHash)

    match, err := verifyPassword("somePassword", encodedHash)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("\nPassword verification success: %v\n", match)
}

func generateHashFromPassword(password string, p *params) (encodedHash string, err error) {
    salt, err := generateRandomBytes(p.saltLength)
    if err != nil {
        return "", err
    }

    hash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

    // Base64 encode the salt and hashed password.
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)

    // Return a string using the standard encoded hash representation.
    encodedHash = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash)

    return encodedHash, nil
}

func generateRandomBytes(n uint32) ([]byte, error) {
    b := make([]byte, n)
    _, err := rand.Read(b)
    if err != nil {
        return nil, err
    }

    return b, nil
}

func verifyPassword(password, encodedHash string) (match bool, err error) {
    // Extract the parameters, salt and derived key from the encoded password
    // hash.
    p, salt, hash, err := decodeHash(encodedHash)
    if err != nil {
        return false, err
    }

    // Derive the key from the other password using the same parameters.
    otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)

    // Check that the contents of the hashed passwords are identical. Note
    // that we are using the subtle.ConstantTimeCompare() function for this
    // to help prevent timing attacks.
    if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
        return true, nil
    }
    return false, nil
}

func decodeHash(encodedHash string) (p *params, salt, hash []byte, err error) {
    vals := strings.Split(encodedHash, "$")
    if len(vals) != 6 {
        return nil, nil, nil, errors.New("the encoded hash is not in the correct format")
    }

    var version int
    _, err = fmt.Sscanf(vals[2], "v=%d", &version)
    if err != nil {
        return nil, nil, nil, err
    }
    if version != argon2.Version {
        return nil, nil, nil, errors.New("incompatible version of argon2")
    }

    p = &params{}
    _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
    if err != nil {
        return nil, nil, nil, err
    }

    salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4])
    if err != nil {
        return nil, nil, nil, err
    }
    p.saltLength = uint32(len(salt))

    hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5])
    if err != nil {
        return nil, nil, nil, err
    }
    p.keyLength = uint32(len(hash))

    return p, salt, hash, nil
}

蟒蛇

import argon2

argon2Hasher = argon2.PasswordHasher(
    time_cost=3, # number of iterations
    memory_cost=64 * 1024, # 64mb
    parallelism=1, # how many parallel threads to use
    hash_len=32, # the size of the derived key
    salt_len=16 # the size of the random generated salt in bytes
)


password = "somePassword"

hash = argon2Hasher.hash(password)

print("Hash + salt of password", hash)

verifyValid = argon2Hasher.verify(hash, password)
print("Password verification success:", verifyValid)

爪哇

import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;

public class PasswordHashing {
    public static void main(String[] args) {
        // salt 32 bytes
        // Hash length 64 bytes
        Argon2 argon2 = Argon2Factory.create(
                Argon2Factory.Argon2Types.ARGON2id,
                16,
                32);

        char[] password = "somePassword".toCharArray();
        String hash = argon2.hash(3, // Number of iterations
                64 * 1024, // 64mb
                1, // how many parallel threads to use
                password);
        System.out.println("Hash + salt of the password: "+hash);
        System.out.println("Password verification success: "+ argon2.verify(hash, password));
    }
}

脚注:

"5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5""12345"

3.https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html^

** 写于SuperTokens的人们——希望你喜欢!我们始终在我们的Discord服务器上可用。如果您有任何问题或需要任何帮助,请加入我们。**