diff --git a/integration_tests/src/Test/Crypto.gren b/integration_tests/src/Test/Crypto.gren index 2b4d4d4..a511a23 100644 --- a/integration_tests/src/Test/Crypto.gren +++ b/integration_tests/src/Test/Crypto.gren @@ -34,6 +34,7 @@ tests = , ecdsaTests secureContext , ed25519Tests secureContext , digestTests secureContext + , pbkdf2Tests secureContext ] ) , await Bytes.getHostEndianness @@ -3040,6 +3041,358 @@ digestTestsHelper secureContext label digestAlgorithm = +-- PBKDF2 Tests + + +{-|-} +pbkdf2Tests secureContext = + let + password = + Bytes.fromString "examplepassword12345" + + -- 16 bytes — meets the minimum salt length + salt = + Bytes.fromString "0123456789abcdef" + + differentSalt = + Bytes.fromString "fedcba9876543210" + + -- SHA-512 with the OWASP minimum iterations: the cheapest valid combo + baseParams = + { salt = salt + , iterations = 210000 + , hash = Crypto.Sha512 + } + in + concat + [ await + (Crypto.importPbkdf2KeyFromRaw secureContext password) + "PBKDF2: Importing a password as raw bytes" + (\key -> + concat + [ await + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 256) key) + "Deriving 256 bits with SHA-512" + (\hash -> + concat + [ test "The derived bytes are not equal to the original password" + (\_ -> + Expect.notEqual hash password + ) + , await + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 256) key) + "Deriving with the same parameters again" + (\secondHash -> + test "Deriving with the same parameters produces the same bytes" + (\_ -> + Expect.equal hash secondHash + ) + ) + , await + (Crypto.deriveBitsWithPbkdf2 + { salt = differentSalt + , iterations = 210000 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving with a different salt" + (\hashWithDifferentSalt -> + test "A different salt produces different bytes" + (\_ -> + Expect.notEqual hashWithDifferentSalt hash + ) + ) + , await + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 220000 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving with a different iteration count" + (\hashWithDifferentIterations -> + test "A different iteration count produces different bytes" + (\_ -> + Expect.notEqual hashWithDifferentIterations hash + ) + ) + , await + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 600000 + , hash = Crypto.Sha256 + } + (Just 256) + key + ) + "Deriving with a different hash algorithm" + (\hashWithDifferentAlgorithm -> + test "A different hash algorithm produces different bytes" + (\_ -> + Expect.notEqual hashWithDifferentAlgorithm hash + ) + ) + , await + (Crypto.deriveBitsWithPbkdf2 baseParams Nothing key) + "Deriving with the default length (Nothing) for SHA-512" + (\defaultHash -> + concat + [ test "Defaults to 512 bits (64 bytes) for SHA-512" + (\_ -> + Expect.equal (Bytes.length defaultHash) 64 + ) + , await + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 512) key) + "Deriving with an explicit 512-bit length" + (\explicitHash -> + test "Nothing matches an explicit Just 512 for SHA-512" + (\_ -> + Expect.equal defaultHash explicitHash + ) + ) + ] + ) + , await + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 600000 + , hash = Crypto.Sha256 + } + Nothing + key + ) + "Deriving with the default length (Nothing) for SHA-256" + (\defaultHash -> + test "Defaults to 256 bits (32 bytes) for SHA-256" + (\_ -> + Expect.equal (Bytes.length defaultHash) 32 + ) + ) + , await + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 600000 + , hash = Crypto.Sha384 + } + Nothing + key + ) + "Deriving with the default length (Nothing) for SHA-384" + (\defaultHash -> + test "Defaults to 384 bits (48 bytes) for SHA-384" + (\_ -> + Expect.equal (Bytes.length defaultHash) 48 + ) + ) + ] + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 100) key) + "Deriving bits with a length that is not a multiple of 8" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorInvalidLength" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorInvalidLength + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 0) key) + "Deriving bits with a length of 0" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorInvalidLength" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorInvalidLength + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = Bytes.fromString "tooshort" + , iterations = 210000 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving bits with an 8-byte salt" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorSaltTooShort" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorSaltTooShort + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + -- 15 bytes — one short of the minimum + { salt = Bytes.fromString "0123456789abcde" + , iterations = 210000 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving bits with a salt of exactly 15 bytes (one below minimum)" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorSaltTooShort" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorSaltTooShort + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = Bytes.fromString "" + , iterations = 210000 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving bits with an empty salt" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorSaltTooShort" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorSaltTooShort + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 209999 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving bits with iterations one below the SHA-512 minimum" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorIterationsInvalid" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorIterationsInvalid + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 599999 + , hash = Crypto.Sha256 + } + (Just 256) + key + ) + "Deriving bits with iterations one below the SHA-256 minimum" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorIterationsInvalid" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorIterationsInvalid + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 599999 + , hash = Crypto.Sha384 + } + (Just 256) + key + ) + "Deriving bits with iterations one below the SHA-384 minimum" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorIterationsInvalid" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorIterationsInvalid + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = 0 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving bits with zero iterations" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorIterationsInvalid" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorIterationsInvalid + ) + ) + , awaitError + (Crypto.deriveBitsWithPbkdf2 + { salt = salt + , iterations = -1 + , hash = Crypto.Sha512 + } + (Just 256) + key + ) + "Deriving bits with negative iterations" + (\err -> + test "Fails with Pbkdf2DeriveBitsErrorIterationsInvalid" + (\_ -> + Expect.equal err Crypto.Pbkdf2DeriveBitsErrorIterationsInvalid + ) + ) + , await + (Crypto.importPbkdf2KeyFromRaw secureContext (Bytes.fromString "different password")) + "Importing a different password" + (\differentKey -> + await + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 256) differentKey) + "Deriving from the different password" + (\differentPasswordHash -> + await + (Crypto.deriveBitsWithPbkdf2 baseParams (Just 256) key) + "Deriving from the original password again" + (\originalPasswordHash -> + test "Different passwords produce different bytes" + (\_ -> + Expect.notEqual differentPasswordHash originalPasswordHash + ) + ) + ) + ) + , await + (Crypto.deriveBitsWithPbkdf2 baseParams Nothing key) + "Password validation: deriving the stored hash" + (\storedHash -> + concat + [ await + (Crypto.importPbkdf2KeyFromRaw secureContext password + |> Task.mapError (\_ -> Crypto.Pbkdf2DeriveBitsError) + |> Task.andThen (Crypto.deriveBitsWithPbkdf2 baseParams Nothing) + ) + "Re-deriving with the correct password" + (\correctHash -> + test "Correct password produces a hash equal to the stored hash" + (\_ -> + Expect.equal correctHash storedHash + ) + ) + , await + (Crypto.importPbkdf2KeyFromRaw secureContext (Bytes.fromString "wrong password") + |> Task.mapError (\_ -> Crypto.Pbkdf2DeriveBitsError) + |> Task.andThen (Crypto.deriveBitsWithPbkdf2 baseParams Nothing) + ) + "Re-deriving with an incorrect password" + (\wrongHash -> + test "Incorrect password produces a hash not equal to the stored hash" + (\_ -> + Expect.notEqual wrongHash storedHash + ) + ) + ] + ) + ] + ) + ] + + + -- Utilities diff --git a/src/Crypto.gren b/src/Crypto.gren index 9dfd1d2..1e88e6e 100644 --- a/src/Crypto.gren +++ b/src/Crypto.gren @@ -72,6 +72,9 @@ module Crypto exposing , importEd25519PrivateKeyFromPkcs8, importEd25519PrivateKeyFromJwk , ImportHmacKeyError(..) , importHmacKeyFromJwk, importHmacKeyFromRaw + , Pbkdf2Key, Pbkdf2Params + , ImportPbkdf2KeyError(..), importPbkdf2KeyFromRaw + , Pbkdf2DeriveBitsError(..), deriveBitsWithPbkdf2 ) @@ -93,8 +96,9 @@ module supports the following algorithms and operations: - Ed25519 - [Signing](#signWithEd25519) and [verifying](#verifyWithEd25519) - HMAC - [Signing](#signWithHmac) and [verifying](#verifyWithHmac) - SHA - [Digest](#digest) +- PBKDF2 - [Derive bits](#deriveBitsWithPbkdf2) for password hashing -All of the above algorithms also have appropriate key generation, import, and +All of the above algorithms also have appropriate key generation, import, and export functions. ## Secure Context @@ -385,6 +389,31 @@ for the Web Crypto API. @docs importHmacKeyFromJwk, importHmacKeyFromRaw +## Password Hashing (PBKDF2) + +Derive bits from a password using the PBKDF2 (Password-Based Key Derivation Function 2) +algorithm. This is the recommended way to hash passwords for storage. + +To hash a password: + +1. Convert the password to `Bytes` (for example, with `Bytes.fromString`) and import + it using [`importPbkdf2KeyFromRaw`](#importPbkdf2KeyFromRaw). +2. Generate a random salt (at least 16 bytes) using + [`getRandomUInt8Values`](#getRandomUInt8Values). Store this salt alongside the + resulting hash so the same hash can be reproduced later. +3. Call [`deriveBitsWithPbkdf2`](#deriveBitsWithPbkdf2) with the imported key, + the salt, an iteration count (at least 600,000 for SHA-256 and SHA-384, or + 210,000 for SHA-512 — higher is better), a hash algorithm, and an optional + length of the hash in bits. Pass `Nothing` for the length to default to the + hash's native output size (256 bits for SHA-256, 384 for SHA-384, 512 for + SHA-512), which is the recommended choice for password hashing. + +@docs Pbkdf2Key, Pbkdf2Params + +@docs ImportPbkdf2KeyError, importPbkdf2KeyFromRaw + +@docs Pbkdf2DeriveBitsError, deriveBitsWithPbkdf2 + -} @@ -2206,6 +2235,146 @@ digest _context algorithm data = data +{-| The native output size, in bits, of the given `DigestAlgorithm`. +-} +digestAlgorithmOutputBits : DigestAlgorithm -> Int +digestAlgorithmOutputBits hash = + when hash is + Sha256 -> + 256 + + Sha384 -> + 384 + + Sha512 -> + 512 + + + +-- PBKDF2 + + +{-| A key, imported from password material, for use with the PBKDF2 algorithm. +-} +type Pbkdf2Key + = Pbkdf2Key + + +{-| Parameters required to derive bits using the PBKDF2 algorithm. + +- `salt` must be at least 16 bytes. The same salt must be reused to reproduce +the same hash for a given password. Generate one with +[`getRandomUInt8Values`](#getRandomUInt8Values). +- `iterations` is the number of times the underlying hash function is applied. +Higher values are more resistant to brute force attacks. A minimum is enforced +based on the `hash` algorithm, per OWASP recommendations: 600,000 for SHA-256 +and SHA-384, and 210,000 for SHA-512. These are minimums — higher values are +better. +- `hash` is the `DigestAlgorithm` used internally by PBKDF2. +-} +type alias Pbkdf2Params = + { salt : Bytes + , iterations : Int + , hash : DigestAlgorithm + } + + +{-| Errors that can happen when importing a key for use with the PBKDF2 +algorithm. + +- `ImportPbkdf2KeyError` happens when the passed `Bytes` cannot be imported as +a PBKDF2 key. +-} +type ImportPbkdf2KeyError + = ImportPbkdf2KeyError + + +{-| Errors that can happen when deriving bits with the PBKDF2 algorithm. + +- `Pbkdf2DeriveBitsErrorSaltTooShort` happens when the `salt` in `Pbkdf2Params` +is less than 16 bytes. +- `Pbkdf2DeriveBitsErrorIterationsInvalid` happens when `iterations` is lower +than the OWASP-recommended minimum for the chosen `hash` (600,000 for SHA-256 +and SHA-384, 210,000 for SHA-512), including zero or negative values. +- `Pbkdf2DeriveBitsErrorInvalidLength` happens when the requested length, in +bits, is not a non-zero multiple of 8. +- `Pbkdf2DeriveBitsError` captures any other unexpected errors. +-} +type Pbkdf2DeriveBitsError + = Pbkdf2DeriveBitsErrorSaltTooShort + | Pbkdf2DeriveBitsErrorIterationsInvalid + | Pbkdf2DeriveBitsErrorInvalidLength + | Pbkdf2DeriveBitsError + + +{-| Import some `Bytes` as a PBKDF2 key. Typically these `Bytes` are produced +by encoding a password (for example, with `Bytes.fromString`). + +The resulting key can be used with [`deriveBitsWithPbkdf2`](#deriveBitsWithPbkdf2) +to produce a hash of the password. +-} +importPbkdf2KeyFromRaw : SecureContext -> Bytes -> Task ImportPbkdf2KeyError (Key Pbkdf2Key {}) +importPbkdf2KeyFromRaw _context bytes = + Gren.Kernel.Crypto.importPbkdf2Key + bytes + [ "deriveBits" ] + + +{-| Derive some `Bytes` from a PBKDF2 key. + +The `Maybe Int` is the number of bits to derive: if `Just`, the value must be +a non-zero multiple of 8; if `Nothing`, defaults to the native output size of +the chosen `hash` (256 bits for SHA-256, 384 for SHA-384, 512 for SHA-512), +which is the recommended size for password hashing. + +Be sure to store the resulting `Bytes` together with the `salt` and `iterations` +from the [`Pbkdf2Params`](#Pbkdf2Params) so the hash can be reproduced when +verifying a password later. +-} +deriveBitsWithPbkdf2 : Pbkdf2Params -> Maybe Int -> Key Pbkdf2Key {} -> Task Pbkdf2DeriveBitsError Bytes +deriveBitsWithPbkdf2 { salt, iterations, hash } maybeLength (Key { key }) = + let + length = + maybeLength + |> Maybe.withDefault (digestAlgorithmOutputBits hash) + in + if Bytes.length salt < 16 then + Task.fail Pbkdf2DeriveBitsErrorSaltTooShort + + else if iterations < pbkdf2MinIterations hash then + Task.fail Pbkdf2DeriveBitsErrorIterationsInvalid + + else if length > 0 && Math.remainderBy 8 length == 0 then + Gren.Kernel.Crypto.deriveBitsWithPbkdf2 + salt + iterations + (digestAlgorithmToString hash) + length + key + + else + Task.fail Pbkdf2DeriveBitsErrorInvalidLength + + +{-| The minimum number of iterations required by the PBKDF2 algorithm for the +given `DigestAlgorithm`. These values match the OWASP recommendations for +password hashing — see the current figures at: + +https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 +-} +pbkdf2MinIterations : DigestAlgorithm -> Int +pbkdf2MinIterations hash = + when hash is + Sha256 -> + 600000 + + Sha384 -> + 600000 + + Sha512 -> + 210000 + + -- UTILITIES diff --git a/src/Gren/Kernel/Crypto.js b/src/Gren/Kernel/Crypto.js index f1bacf5..edc65de 100644 --- a/src/Gren/Kernel/Crypto.js +++ b/src/Gren/Kernel/Crypto.js @@ -2,7 +2,7 @@ import Gren.Kernel.Scheduler exposing (binding, succeed, fail) import Gren.Kernel.Bytes exposing (writeBytes) -import Crypto exposing (RsaSsaPkcs1V1_5SigningError, RsaPssSigningError, AesCtrEncryptionError, RsaOaepEncryptionError, RsaOaepDecryptionError, P256, P384, P521, AesLength128, AesLength192, AesLength256, CanBeExtracted, CannotBeExtracted, HmacKey, Ed25519Key, Sha256, Sha384, Sha512, SignWithRsaPssError, AesGcmDecryptionError, AesGcmEncryptionError, AesCbcDecryptionError, AesCbcEncryptionError, AesCtrDecryptionError, DecryptWithRsaOaepError, ImportRsaKeyError, ImportHmacKeyError, ImportEcKeyError, ImportEd25519KeyError, ImportEd25519KeyNotSupported, Ed25519KeyGenerationNotSupported, ImportAesKeyError, Key, SecureContext, PublicKey, PrivateKey, KeyNotExportable) +import Crypto exposing (RsaSsaPkcs1V1_5SigningError, RsaPssSigningError, AesCtrEncryptionError, RsaOaepEncryptionError, RsaOaepDecryptionError, P256, P384, P521, AesLength128, AesLength192, AesLength256, CanBeExtracted, CannotBeExtracted, HmacKey, Ed25519Key, Sha256, Sha384, Sha512, SignWithRsaPssError, AesGcmDecryptionError, AesGcmEncryptionError, AesCbcDecryptionError, AesCbcEncryptionError, AesCtrDecryptionError, DecryptWithRsaOaepError, ImportRsaKeyError, ImportHmacKeyError, ImportEcKeyError, ImportEd25519KeyError, ImportEd25519KeyNotSupported, Ed25519KeyGenerationNotSupported, ImportAesKeyError, ImportPbkdf2KeyError, Pbkdf2DeriveBitsError, Key, SecureContext, PublicKey, PrivateKey, KeyNotExportable) import Maybe exposing (Just, Nothing) import Bytes exposing (Bytes) @@ -846,6 +846,49 @@ var _Crypto_verifyWithHmac = F3(function (key, signature, bytes) { }); }); +// PBKDF2 + +var _Crypto_importPbkdf2Key = F2(function (keyData, keyUsages) { + return __Scheduler_binding(function (callback) { + _Crypto_impl.subtle + .importKey("raw", keyData, { name: "PBKDF2" }, false, keyUsages) + .then(function (key) { + return callback( + __Scheduler_succeed( + __Crypto_Key({ + __$key: key, + __$data: {}, + }), + ), + ); + }) + .catch(function (err) { + return callback(__Scheduler_fail(__Crypto_ImportPbkdf2KeyError)); + }); + }); +}); + +var _Crypto_deriveBitsWithPbkdf2 = F5( + function (salt, iterations, hash, length, key) { + return __Scheduler_binding(function (callback) { + var algorithm = { + name: "PBKDF2", + salt: salt, + iterations: iterations, + hash: hash, + }; + _Crypto_impl.subtle + .deriveBits(algorithm, key, length) + .then(function (res) { + return callback(__Scheduler_succeed(new DataView(res))); + }) + .catch(function (err) { + return callback(__Scheduler_fail(__Crypto_Pbkdf2DeriveBitsError)); + }); + }); + }, +); + // Digest var _Crypto_digest = F2(function (algorithm, bytes) {