diff --git a/ballerina/Ballerina.toml b/ballerina/Ballerina.toml index 6c7f6f3d..6ca89f60 100644 --- a/ballerina/Ballerina.toml +++ b/ballerina/Ballerina.toml @@ -1,7 +1,7 @@ [package] org = "ballerina" name = "crypto" -version = "2.9.0" +version = "2.9.1" authors = ["Ballerina"] keywords = ["security", "hash", "hmac", "sign", "encrypt", "decrypt", "private key", "public key"] repository = "https://github.com/ballerina-platform/module-ballerina-crypto" @@ -15,8 +15,8 @@ graalvmCompatible = true [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "crypto-native" -version = "2.9.0" -path = "../native/build/libs/crypto-native-2.9.0.jar" +version = "2.9.1" +path = "../native/build/libs/crypto-native-2.9.1-SNAPSHOT.jar" [[platform.java21.dependency]] groupId = "org.bouncycastle" diff --git a/ballerina/Dependencies.toml b/ballerina/Dependencies.toml index d79d394d..6488f734 100644 --- a/ballerina/Dependencies.toml +++ b/ballerina/Dependencies.toml @@ -10,7 +10,7 @@ distribution-version = "2201.12.0" [[package]] org = "ballerina" name = "crypto" -version = "2.9.0" +version = "2.9.1" dependencies = [ {org = "ballerina", name = "io"}, {org = "ballerina", name = "jballerina.java"}, diff --git a/ballerina/hash.bal b/ballerina/hash.bal index 3fb4671a..3e7ddace 100644 --- a/ballerina/hash.bal +++ b/ballerina/hash.bal @@ -16,6 +16,13 @@ import ballerina/jballerina.java; +# Supported HMAC algorithms for PBKDF2 +public enum HmacAlgorithm { + SHA1, + SHA256, + SHA512 +} + # Returns the MD5 hash of the given data. # ```ballerina # string dataString = "Hello Ballerina"; @@ -162,7 +169,7 @@ public isolated function verifyBcrypt(string password, string hashedPassword) re # + return - Argon2id hashed password string or Error if hashing fails public isolated function hashArgon2(string password, int iterations = 3, int memory = 65536, int parallelism = 4) returns string|Error = @java:Method { name: "hashPasswordArgon2", - 'class: "io.ballerina.stdlib.crypto.nativeimpl.PasswordArgon2" + 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" } external; # Verifies if a password matches an Argon2id hashed password. @@ -177,5 +184,35 @@ public isolated function hashArgon2(string password, int iterations = 3, int mem # + return - Boolean indicating if password matches or Error if verification fails public isolated function verifyArgon2(string password, string hashedPassword) returns boolean|Error = @java:Method { name: "verifyPasswordArgon2", - 'class: "io.ballerina.stdlib.crypto.nativeimpl.PasswordArgon2" + 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" +} external; + +# Returns a PBKDF2 hash of the given password with optional parameters. +# ```ballerina +# string password = "mySecurePassword123"; +# string|crypto:Error hash = crypto:hashPbkdf2(password); +# ``` +# +# + password - Password string to be hashed +# + iterations - Optional number of iterations. Default is 10000 +# + algorithm - Optional HMAC algorithm (`SHA1`, `SHA256`, `SHA512`). Default is SHA256 +# + return - PBKDF2 hashed password string or Error if hashing fails +public isolated function hashPbkdf2(string password, int iterations = 10000, HmacAlgorithm algorithm = SHA256) returns string|Error = @java:Method { + name: "hashPasswordPBKDF2", + 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" +} external; + +# Verifies if a password matches a PBKDF2 hashed password. +# ```ballerina +# string password = "mySecurePassword123"; +# string hashedPassword = "$pbkdf2-sha256$i=10000$salt$hash"; +# boolean|crypto:Error matches = crypto:verifyPbkdf2(password, hashedPassword); +# ``` +# +# + password - Password string to verify +# + hashedPassword - PBKDF2 hashed password to verify against +# + return - Boolean indicating if password matches or Error if verification fails +public isolated function verifyPbkdf2(string password, string hashedPassword) returns boolean|Error = @java:Method { + name: "verifyPasswordPBKDF2", + 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" } external; diff --git a/ballerina/tests/hash_test.bal b/ballerina/tests/hash_test.bal index 83543b6d..ebaef06e 100644 --- a/ballerina/tests/hash_test.bal +++ b/ballerina/tests/hash_test.bal @@ -46,6 +46,11 @@ type InvalidHash record {| string expectedError; |}; +type InvalidPbkdf2Params record {| + int iterations; + HmacAlgorithm algorithm; + string expectedError; +|}; @test:Config {} isolated function testHashCrc32() { @@ -134,7 +139,6 @@ isolated function testHashSha512WithSalt() { test:assertEquals(hashSha512(input, salt).toBase16(), expectedSha512Hash); } - @test:Config {} isolated function testHashKeccak256() { byte[] input = "Ballerina test".toBytes(); @@ -167,7 +171,7 @@ isolated function testHashPasswordArgon2ComplexPasswords(ComplexPassword data) r string hash = check hashArgon2(data.password); test:assertTrue(hash.startsWith("$argon2id$v=19$")); boolean result = check verifyArgon2(data.password, hash); - test:assertTrue(result, "Password verification failed for: " + data.password); + test:assertTrue(result, string `Password verification failed for: ${data.password}`); } @test:Config { @@ -188,7 +192,7 @@ isolated function testHashPasswordArgon2InvalidParams(InvalidArgon2Params data) isolated function testVerifyPasswordArgon2Success(ValidPassword data) returns error? { string hash = check hashArgon2(data.password); boolean result = check verifyArgon2(data.password, hash); - test:assertTrue(result, "Password verification failed for: " + data.password); + test:assertTrue(result, string `Password verification failed for: ${data.password}`); } @test:Config { @@ -197,7 +201,7 @@ isolated function testVerifyPasswordArgon2Success(ValidPassword data) returns er isolated function testVerifyPasswordArgon2Failure(PasswordPair data) returns error? { string hash = check hashArgon2(data.correctPassword); boolean result = check verifyArgon2(data.wrongPassword, hash); - test:assertFalse(result, "Should fail for wrong password: " + data.wrongPassword); + test:assertFalse(result, string `Should fail for wrong password: ${data.wrongPassword}`); } @test:Config { @@ -207,7 +211,7 @@ isolated function testVerifyPasswordArgon2InvalidHashFormat(InvalidHash data) { string password = "Ballerina@123"; boolean|Error result = verifyArgon2(password, data.hash); if result !is Error { - test:assertFail("Should fail with invalid hash: " + data.hash); + test:assertFail(string `Should fail with invalid hash: ${data.hash}`); } test:assertTrue(result.message().startsWith("Invalid Argon2 hash format")); } @@ -220,16 +224,16 @@ isolated function testArgon2PasswordHashUniqueness(ValidPassword data) returns e string hash2 = check hashArgon2(data.password); string hash3 = check hashArgon2(data.password); - test:assertNotEquals(hash1, hash2, "Hashes should be unique for: " + data.password); - test:assertNotEquals(hash2, hash3, "Hashes should be unique for: " + data.password); - test:assertNotEquals(hash1, hash3, "Hashes should be unique for: " + data.password); + test:assertNotEquals(hash1, hash2, string `Hashes should be unique for: ${data.password}`); + test:assertNotEquals(hash2, hash3, string `Hashes should be unique for: ${data.password}`); + test:assertNotEquals(hash1, hash3, string `Hashes should be unique for: ${data.password}`); boolean verify1 = check verifyArgon2(data.password, hash1); boolean verify2 = check verifyArgon2(data.password, hash2); boolean verify3 = check verifyArgon2(data.password, hash3); test:assertTrue(verify1 && verify2 && verify3, - "All hashes should verify successfully for: " + data.password); + string `All hashes should verify successfully for: ${data.password}`); } // tests for Bcrypt @@ -258,7 +262,7 @@ isolated function testHashPasswordBcryptComplexPasswords(ComplexPassword data) r test:assertTrue(hash.length() > 50); boolean result = check verifyBcrypt(data.password, hash); - test:assertTrue(result, "Password verification failed for: " + data.password); + test:assertTrue(result, string `Password verification failed for: ${data.password}`); } @test:Config { @@ -279,7 +283,7 @@ isolated function testHashPasswordBcryptInvalidWorkFactor(InvalidWorkFactor data isolated function testVerifyPasswordBcryptSuccess(ValidPassword data) returns error? { string hash = check hashBcrypt(data.password); boolean result = check verifyBcrypt(data.password, hash); - test:assertTrue(result, "Password verification failed for: " + data.password); + test:assertTrue(result, string `Password verification failed for: ${data.password}`); } @test:Config { @@ -288,7 +292,7 @@ isolated function testVerifyPasswordBcryptSuccess(ValidPassword data) returns er isolated function testVerifyPasswordBcryptFailure(PasswordPair data) returns error? { string hash = check hashBcrypt(data.correctPassword); boolean result = check verifyBcrypt(data.wrongPassword, hash); - test:assertFalse(result, "Should fail for wrong password: " + data.wrongPassword); + test:assertFalse(result, string `Should fail for wrong password: ${data.wrongPassword}`); } @test:Config { @@ -298,7 +302,7 @@ isolated function testVerifyPasswordBcryptInvalidHashFormat(InvalidHash data) { string password = "Ballerina@123"; boolean|Error result = verifyBcrypt(password, data.hash); if result !is Error { - test:assertFail("Should fail with invalid hash: " + data.hash); + test:assertFail(string `Should fail with invalid hash: ${data.hash}`); } test:assertEquals(result.message(), data.expectedError); } @@ -311,16 +315,16 @@ isolated function testBcryptPasswordHashUniqueness(ValidPassword data) returns e string hash2 = check hashBcrypt(data.password); string hash3 = check hashBcrypt(data.password); - test:assertNotEquals(hash1, hash2, "Hashes should be unique for: " + data.password); - test:assertNotEquals(hash2, hash3, "Hashes should be unique for: " + data.password); - test:assertNotEquals(hash1, hash3, "Hashes should be unique for: " + data.password); + test:assertNotEquals(hash1, hash2, string `Hashes should be unique for: ${data.password}`); + test:assertNotEquals(hash2, hash3, string `Hashes should be unique for: ${data.password}`); + test:assertNotEquals(hash1, hash3, string `Hashes should be unique for: ${data.password}`); boolean verify1 = check verifyBcrypt(data.password, hash1); boolean verify2 = check verifyBcrypt(data.password, hash2); boolean verify3 = check verifyBcrypt(data.password, hash3); test:assertTrue(verify1 && verify2 && verify3, - "All hashes should verify successfully for: " + data.password); + string `All hashes should verify successfully for: ${data.password}`); } // common tests for both algorithms @@ -336,6 +340,109 @@ isolated function testEmptyPasswordError(string algorithm) returns error? { test:assertEquals(hash.message(), "Password cannot be empty"); } +// tests for PBKDF2 +@test:Config {} +isolated function testHashPasswordPbkdf2Default() returns error? { + string password = "Ballerina@123"; + string hash = check hashPbkdf2(password); + test:assertTrue(hash.startsWith("$pbkdf2-sha256$i=10000$")); + test:assertTrue(hash.length() > 50); +} + +@test:Config {} +isolated function testHashPasswordPbkdf2Custom() returns error? { + string password = "Ballerina@123"; + string hash = check hashPbkdf2(password, 15000, SHA512); + test:assertTrue(hash.startsWith(string `$pbkdf2-sha512$i=15000$`)); + test:assertTrue(hash.length() > 50); +} + +@test:Config { + dataProvider: complexPasswordsDataProvider +} +isolated function testHashPasswordPbkdf2ComplexPasswords(ComplexPassword data) returns error? { + string hash = check hashPbkdf2(data.password); + test:assertTrue(hash.startsWith("$pbkdf2-sha256$i=10000$")); + boolean result = check verifyPbkdf2(data.password, hash); + test:assertTrue(result, string `Password verification failed for: ${data.password}`); +} + +@test:Config { + dataProvider: invalidPbkdf2ParamsDataProvider +} +isolated function testHashPasswordPbkdf2InvalidParams(InvalidPbkdf2Params data) { + string password = "Ballerina@123"; + string|Error hash = hashPbkdf2(password, data.iterations, data.algorithm); + if hash !is Error { + test:assertFail(string `Should fail with invalid parameters: iterations=${data.iterations}, algorithm=${data.algorithm}`); + } + test:assertEquals(hash.message(), data.expectedError); +} + +@test:Config { + dataProvider: validPasswordsDataProvider +} +isolated function testVerifyPasswordPbkdf2Success(ValidPassword data) returns error? { + string hash = check hashPbkdf2(data.password); + boolean result = check verifyPbkdf2(data.password, hash); + test:assertTrue(result, string `Password verification failed for: ${data.password}`); +} + +@test:Config { + dataProvider: wrongPasswordsDataProvider +} +isolated function testVerifyPasswordPbkdf2Failure(PasswordPair data) returns error? { + string hash = check hashPbkdf2(data.correctPassword); + boolean result = check verifyPbkdf2(data.wrongPassword, hash); + test:assertFalse(result, string `Should fail for wrong password: ${data.wrongPassword}`); +} + +@test:Config { + dataProvider: invalidPbkdf2HashesDataProvider +} +isolated function testVerifyPasswordPbkdf2InvalidHashFormat(InvalidHash data) { + string password = "Ballerina@123"; + boolean|Error result = verifyPbkdf2(password, data.hash); + if result !is Error { + test:assertFail(string `Should fail with invalid hash: ${data.hash}`); + } + test:assertTrue(result.message().startsWith(data.expectedError)); +} + +@test:Config { + dataProvider: uniquenessPasswordsDataProvider +} +isolated function testPbkdf2PasswordHashUniqueness(ValidPassword data) returns error? { + string hash1 = check hashPbkdf2(data.password); + string hash2 = check hashPbkdf2(data.password); + string hash3 = check hashPbkdf2(data.password); + + test:assertNotEquals(hash1, hash2, string `Hashes should be unique for: ${data.password}`); + test:assertNotEquals(hash2, hash3, string `Hashes should be unique for: ${data.password}`); + test:assertNotEquals(hash1, hash3, string `Hashes should be unique for: ${data.password}`); + + boolean verify1 = check verifyPbkdf2(data.password, hash1); + boolean verify2 = check verifyPbkdf2(data.password, hash2); + boolean verify3 = check verifyPbkdf2(data.password, hash3); + + test:assertTrue(verify1 && verify2 && verify3, + string `All hashes should verify successfully for: ${data.password}`); +} + +@test:Config { + dataProvider: pbkdf2AlgorithmsDataProvider +} +isolated function testPbkdf2DifferentAlgorithms(HmacAlgorithm algorithm) returns error? { + string password = "Ballerina@123"; + string hash = check hashPbkdf2(password, 10000, algorithm); + + test:assertTrue(hash.startsWith(string `$pbkdf2-${algorithm.toString().toLowerAscii()}$`), + string `Hash should start with correct algorithm identifier: ${algorithm.toString()}`); + + boolean result = check verifyPbkdf2(password, hash); + test:assertTrue(result, string `Password verification failed for algorithm: ${algorithm.toString()}`); +} + // data Providers for password tests isolated function complexPasswordsDataProvider() returns ComplexPassword[][] { return [ @@ -429,3 +536,29 @@ isolated function hashingAlgorithmsDataProvider() returns string[][] { ["bcrypt"] ]; } + +// Additional data providers for PBKDF2 tests +isolated function invalidPbkdf2ParamsDataProvider() returns InvalidPbkdf2Params[][] { + return [ + [{iterations: 0, algorithm: SHA256, expectedError: "Iterations must be at least 10000"}], + [{iterations: 500, algorithm: SHA256, expectedError: "Iterations must be at least 10000"}], + [{iterations: -100, algorithm: SHA256, expectedError: "Iterations must be at least 10000"}] + ]; +} + +isolated function invalidPbkdf2HashesDataProvider() returns InvalidHash[][] { + return [ + [{hash: "invalid_hash_format", expectedError: "Invalid PBKDF2 hash format"}], + [{hash: "$pbkdf2-sha256$invalid", expectedError: "Invalid PBKDF2 hash format"}], + [{hash: "$pbkdf2$i=10000$salt$hash", expectedError: "Invalid PBKDF2 hash format"}], + [{hash: "$pbkdf2-md5$i=10000$salt$hash", expectedError: "Error occurred while verifying password: Unsupported algorithm: MD5"}] + ]; +} + +isolated function pbkdf2AlgorithmsDataProvider() returns HmacAlgorithm[][] { + return [ + [SHA1], + [SHA256], + [SHA512] + ]; +} diff --git a/changelog.md b/changelog.md index 22bd71cc..8c36c67b 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added +- [Introduce support for PBKDF2 password hashing and verification](https://github.com/ballerina-platform/ballerina-lang/issues/43926) + ### Changed - [Update OIDS of NIST approved post quantum algorithms](https://github.com/ballerina-platform/ballerina-library/issues/7678) diff --git a/docs/proposals/pbkdf2-hashing-apis.md b/docs/proposals/pbkdf2-hashing-apis.md new file mode 100644 index 00000000..e703cd5b --- /dev/null +++ b/docs/proposals/pbkdf2-hashing-apis.md @@ -0,0 +1,58 @@ +# Proposal: Introduce PBKDF2-Based Password Hashing to Ballerina Crypto Module + +_Authors_: @randilt +_Reviewers_: +_Created_: 2025/03/18 +_Updated_: 2025/03/18 +_Issues_: [#43926](https://github.com/ballerina-platform/ballerina-lang/issues/43926) + +## Summary + +The Ballerina crypto module currently lacks built-in support for PBKDF2 password hashing, a widely used key derivation function for secure password storage. This proposal introduces two new APIs to provide PBKDF2-based password hashing and verification. + +## Goals + +- Introduce PBKDF2 password hashing support with configurable parameters +- Provide a verification function to check hashed passwords against user inputs +- Support common HMAC algorithms (`SHA1`, `SHA256`, `SHA512`) and iteration count customization + +## Motivation + +Password hashing is a fundamental security requirement for authentication systems. PBKDF2 is a widely recognized key derivation function that enhances security by applying multiple iterations of a cryptographic hash function. By integrating PBKDF2 support, the Ballerina crypto module will offer a standardized and secure method for password storage and verification. + +## Description + +This proposal aims to introduce secure PBKDF2 password hashing and verification capabilities in the Ballerina crypto module. + +### API Additions + +#### PBKDF2 Hashing Function + +A new API will be introduced to generate PBKDF2 hashes with configurable parameters: + +```ballerina +public enum HmacAlgorithm { + SHA1, + SHA256, + SHA512 +} + +public isolated function hashPbkdf2( + string password, + int iterations = 10000, + HmacAlgorithm algorithm = SHA256 +) returns string|Error; +``` + +#### PBKDF2 Verification Function + +A corresponding API will be introduced to verify a password against a PBKDF2 hash: + +```ballerina +public isolated function verifyPbkdf2( + string password, + string hashedPassword +) returns boolean|Error; +``` + +These functions will allow developers to securely hash and verify passwords using PBKDF2 with customizable parameters for increased security. diff --git a/docs/spec/spec.md b/docs/spec/spec.md index 4fef99eb..28830500 100644 --- a/docs/spec/spec.md +++ b/docs/spec/spec.md @@ -105,6 +105,7 @@ The conforming implementation of the specification is released and included in t 10. [Password hashing](#10-password-hashing) * 10.1 [BCrypt](#101-bcrypt) * 10.2 [Argon2](#102-argon2) + * 10.3 [PBKDF2](#103-pbkdf2) ## 1. [Overview](#1-overview) @@ -1184,3 +1185,51 @@ string hashedPassword1 = check crypto:hashArgon2(password); string hashedPassword2 = check crypto:hashArgon2(password, iterations = 4, memory = 131072, parallelism = 8); boolean isValid = check crypto:verifyArgon2(password, hashedPassword1); ``` + +### 10.3 [PBKDF2](#103-pbkdf2) + +Implements the PBKDF2 (Password-Based Key Derivation Function 2) algorithm for password hashing. + +```ballerina +public enum HmacAlgorithm { + SHA1, + SHA256, + SHA512 +} +public isolated function hashPbkdf2(string password, int iterations = 10000, + HmacAlgorithm algorithm = SHA256) returns string|Error +``` + +Parameters: + +- `password`: The plain text password to hash +- `iterations`: Optional number of iterations (default: 10000) +- `algorithm`: Optional HMAC algorithm (SHA1, SHA256, SHA512). Default is SHA256 + +Example: + +```ballerina +string password = "mySecurePassword123"; +// Hash with default parameters +string hashedPassword = check crypto:hashPbkdf2(password); +// Hash with custom parameters +string customHashedPassword = check crypto:hashPbkdf2(password, iterations = 15000, algorithm = SHA512); +``` + +```ballerina +public isolated function verifyPbkdf2(string password, string hashedPassword) returns boolean|Error +``` + +Parameters: + +- `password`: The plain text password to verify +- `hashedPassword`: PBKDF2 hashed password to verify against + +Example: + +```ballerina +string password = "mySecurePassword123"; +string hashedPassword = "$pbkdf2-sha256$i=10000$salt$hash"; +// Verify the hashed password +boolean isValid = check crypto:verifyPbkdf2(password, hashedPassword); +``` \ No newline at end of file diff --git a/native/src/main/java/io/ballerina/stdlib/crypto/Constants.java b/native/src/main/java/io/ballerina/stdlib/crypto/Constants.java index 8c432e7b..24c12181 100644 --- a/native/src/main/java/io/ballerina/stdlib/crypto/Constants.java +++ b/native/src/main/java/io/ballerina/stdlib/crypto/Constants.java @@ -143,4 +143,13 @@ private Constants() {} public static final String SHA384 = "SHA-384"; public static final String SHA512 = "SHA-512"; public static final String KECCAK256 = "Keccak-256"; + + // Password hashing constants + public static final String BCRYPT_HASH_FORMAT = "$2a$%02d$%s"; + public static final String ARGON2_SALT_FORMAT = "$argon2id$v=19$m=%d,t=%d,p=%d$%s"; + public static final String ARGON2_HASH_FORMAT = "$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s"; + public static final String PBKDF2_HASH_PATTERN = + "\\$pbkdf2-(\\w+)\\$i=(\\d+)\\$([A-Za-z0-9+/=]+)\\$([A-Za-z0-9+/=]+)"; + public static final String PBKDF2_SALT_FORMAT = "$pbkdf2-%s$i=%d$%s"; + public static final String PBKDF2_HASH_FORMAT = "$pbkdf2-%s$i=%d$%s$%s"; } diff --git a/native/src/main/java/io/ballerina/stdlib/crypto/PasswordUtils.java b/native/src/main/java/io/ballerina/stdlib/crypto/PasswordUtils.java index 2cdcd7f0..8a006275 100644 --- a/native/src/main/java/io/ballerina/stdlib/crypto/PasswordUtils.java +++ b/native/src/main/java/io/ballerina/stdlib/crypto/PasswordUtils.java @@ -38,35 +38,30 @@ public class PasswordUtils { */ public static final int MAX_WORK_FACTOR = 31; - /** - * Default work factor used for BCrypt password hashing if not specified. - */ - public static final int DEFAULT_WORK_FACTOR = 12; - /** * Length of the random salt used in password hashing. */ public static final int SALT_LENGTH = 16; /** - * Default number of iterations for Argon2. + * Length of the generated hash in bytes for Argon2. */ - public static final int DEFAULT_ITERATIONS = 3; - + public static final int HASH_LENGTH = 32; + /** - * Default memory usage in KB (64MB) for Argon2. + * Minimum number of iterations for PBKDF2. */ - public static final int DEFAULT_MEMORY = 65536; - + public static final int MIN_PBKDF2_ITERATIONS = 10000; + /** - * Default number of parallel threads for Argon2. + * Minimum allowed memory cost for PBKDF2. */ - public static final int DEFAULT_PARALLELISM = 4; + public static final int PBKDF2_MIN_MEMORY_COST = 8192; /** - * Length of the generated hash in bytes for Argon2. + * Supported HMAC algorithms for PBKDF2. */ - public static final int HASH_LENGTH = 32; + static final String[] SUPPORTED_PBKDF2_ALGORITHMS = {"SHA1", "SHA256", "SHA512"}; /** * Secure random number generator for salt generation. @@ -89,6 +84,39 @@ public static Object validateWorkFactor(long workFactor) { } return null; } + + /** + * Validate if the provided PBKDF2 iterations is within acceptable bounds. + * + * @param iterations the iterations count to validate + * @return null if valid, error if invalid + */ + public static Object validatePBKDF2Iterations(long iterations) { + if (iterations < MIN_PBKDF2_ITERATIONS) { + return CryptoUtils.createError( + String.format("Iterations must be at least %d", MIN_PBKDF2_ITERATIONS) + ); + } + return null; + } + + /** + * Validate if the provided PBKDF2 algorithm is supported. + * + * @param algorithm the HMAC algorithm to validate + * @return null if valid, error if invalid + */ + public static Object validatePbkdf2Algorithm(String algorithm) { + for (String supportedAlg : SUPPORTED_PBKDF2_ALGORITHMS) { + if (supportedAlg.equalsIgnoreCase(algorithm)) { + return null; + } + } + return CryptoUtils.createError( + String.format("Unsupported algorithm. Must be one of: %s", + String.join(", ", SUPPORTED_PBKDF2_ALGORITHMS)) + ); + } /** * Generate a cryptographically secure random salt. @@ -110,7 +138,7 @@ public static byte[] generateRandomSalt() { */ public static String formatBCryptHash(long workFactor, byte[] saltAndHash) { String saltAndHashBase64 = Base64.toBase64String(saltAndHash); - return String.format(Locale.ROOT, "$2a$%02d$%s", workFactor, saltAndHashBase64); + return String.format(Locale.ROOT, Constants.BCRYPT_HASH_FORMAT, workFactor, saltAndHashBase64); } /** @@ -125,7 +153,7 @@ public static String formatBCryptHash(long workFactor, byte[] saltAndHash) { */ public static String formatArgon2Hash(long memory, long iterations, long parallelism, String saltBase64, String hashBase64) { - return String.format(Locale.ROOT, "$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + return String.format(Locale.ROOT, Constants.ARGON2_HASH_FORMAT, memory, iterations, parallelism, saltBase64, hashBase64); } @@ -139,9 +167,37 @@ public static String formatArgon2Hash(long memory, long iterations, long paralle * @return formatted Argon2 salt string */ public static String formatArgon2Salt(long memory, long iterations, long parallelism, String saltBase64) { - return String.format(Locale.ROOT, "$argon2id$v=19$m=%d,t=%d,p=%d$%s", + return String.format(Locale.ROOT, Constants.ARGON2_SALT_FORMAT, memory, iterations, parallelism, saltBase64); } + + /** + * Format a PBKDF2 hash string according to the standard format. + * + * @param algorithm the HMAC algorithm used + * @param iterations iteration count + * @param saltBase64 Base64 encoded salt + * @param hashBase64 Base64 encoded hash + * @return formatted PBKDF2 hash string + */ + public static String formatPBKDF2Hash(String algorithm, long iterations, + String saltBase64, String hashBase64) { + return String.format(Locale.ROOT, Constants.PBKDF2_HASH_FORMAT, + algorithm.toLowerCase(Locale.ROOT), iterations, saltBase64, hashBase64); + } + + /** + * Format a PBKDF2 salt string according to the standard format. + * + * @param algorithm the HMAC algorithm used + * @param iterations iteration count + * @param saltBase64 Base64 encoded salt + * @return formatted PBKDF2 salt string + */ + public static String formatPBKDF2Salt(String algorithm, long iterations, String saltBase64) { + return String.format(Locale.ROOT, Constants.PBKDF2_SALT_FORMAT, + algorithm.toLowerCase(Locale.ROOT), iterations, saltBase64); + } /** * Combine salt and hash byte arrays into a single array. diff --git a/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Password.java b/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Password.java index 4094cc7a..93e8f6b1 100644 --- a/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Password.java +++ b/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Password.java @@ -19,20 +19,35 @@ import io.ballerina.runtime.api.utils.StringUtils; import io.ballerina.runtime.api.values.BString; +import io.ballerina.stdlib.crypto.Constants; import io.ballerina.stdlib.crypto.CryptoUtils; import io.ballerina.stdlib.crypto.PasswordUtils; +import org.bouncycastle.crypto.generators.Argon2BytesGenerator; import org.bouncycastle.crypto.generators.BCrypt; +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.bouncycastle.util.encoders.Base64; + +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; /** - * Native implementation of BCrypt password hashing functions. - * Provides methods for hashing passwords, verifying hashes, and generating salts using the BCrypt algorithm. - * - * @since ... + * Native implementation of password hashing functions. + * Provides methods for hashing passwords, verifying hashes, and generating salts using + * the BCrypt, Argon2id, and PBKDF2 algorithms. */ public class Password { private Password() {} + // BCrypt methods + /** * Hash a password using BCrypt with a custom work factor. * @@ -62,16 +77,6 @@ public static Object hashPassword(BString password, long workFactor) { } } - /** - * Hash a password using BCrypt with the default work factor. - * - * @param password the password to hash - * @return hashed password string or error - */ - public static Object hashPassword(BString password) { - return hashPassword(password, PasswordUtils.DEFAULT_WORK_FACTOR); - } - /** * Verify a password against a BCrypt hash. * @@ -127,12 +132,278 @@ public static Object generateSalt(long workFactor) { } } + // Argon2 methods + + /** + * Hash a password using Argon2 with custom parameters. + * + * @param password the password to hash + * @param iterations number of iterations + * @param memory memory usage in KB + * @param parallelism number of parallel threads + * @return hashed password string or error + */ + public static Object hashPasswordArgon2(BString password, long iterations, long memory, long parallelism) { + try { + if (iterations <= 0) { + return CryptoUtils.createError("Iterations must be positive"); + } + if (memory < PasswordUtils.PBKDF2_MIN_MEMORY_COST) { + return CryptoUtils.createError(String.format("Memory must be at least %d KB (%dMB)", + PasswordUtils.PBKDF2_MIN_MEMORY_COST, PasswordUtils.PBKDF2_MIN_MEMORY_COST / 1024)); + } + if (parallelism <= 0) { + return CryptoUtils.createError("Parallelism must be positive"); + } + if (password.getValue().length() == 0) { + return CryptoUtils.createError("Password cannot be empty"); + } + + byte[] salt = PasswordUtils.generateRandomSalt(); + + Argon2Parameters params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withSalt(salt) + .withIterations((int) iterations) + .withMemoryAsKB((int) memory) + .withParallelism((int) parallelism) + .build(); + + byte[] hash = new byte[PasswordUtils.HASH_LENGTH]; + Argon2BytesGenerator generator = new Argon2BytesGenerator(); + generator.init(params); + generator.generateBytes(password.getValue().getBytes(StandardCharsets.UTF_8), hash); + + String saltBase64 = Base64.toBase64String(salt); + String hashBase64 = Base64.toBase64String(hash); + + String result = PasswordUtils.formatArgon2Hash(memory, iterations, parallelism, saltBase64, hashBase64); + + return StringUtils.fromString(result); + } catch (Exception e) { + return CryptoUtils.createError("Error occurred while hashing password with Argon2: " + e.getMessage()); + } + } + /** - * Generate a salt string for BCrypt with the default work factor. + * Verify a password against an Argon2 hash. * + * @param password the password to verify + * @param hashedPassword the hashed password to verify against + * @return true if password matches, false if not, or error if verification fails + */ + public static Object verifyPasswordArgon2(BString password, BString hashedPassword) { + try { + String hash = hashedPassword.getValue(); + if (!hash.startsWith("$argon2id$")) { + return CryptoUtils.createError("Invalid Argon2 hash format"); + } + + String[] parts = hash.split("\\$"); + if (parts.length != 6) { + return CryptoUtils.createError("Invalid Argon2 hash format"); + } + + String[] params = parts[3].split(","); + int memory = Integer.parseInt(params[0].substring(2)); + int iterations = Integer.parseInt(params[1].substring(2)); + int parallelism = Integer.parseInt(params[2].substring(2)); + + byte[] salt = Base64.decode(parts[4]); + byte[] originalHash = Base64.decode(parts[5]); + + Argon2Parameters parameters = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withSalt(salt) + .withIterations(iterations) + .withMemoryAsKB(memory) + .withParallelism(parallelism) + .build(); + + byte[] newHash = new byte[PasswordUtils.HASH_LENGTH]; + Argon2BytesGenerator generator = new Argon2BytesGenerator(); + generator.init(parameters); + generator.generateBytes(password.getValue().getBytes(StandardCharsets.UTF_8), newHash); + + return PasswordUtils.constantTimeArrayEquals(newHash, originalHash); + } catch (Exception e) { + return CryptoUtils.createError("Error occurred while verifying password: " + e.getMessage()); + } + } + + /** + * Generate a salt string for Argon2 with custom parameters. + * + * @param iterations number of iterations + * @param memory memory usage in KB + * @param parallelism number of parallel threads * @return formatted salt string or error */ - public static Object generateSalt() { - return generateSalt(PasswordUtils.DEFAULT_WORK_FACTOR); + public static Object generateSaltArgon2(long iterations, long memory, long parallelism) { + try { + byte[] salt = PasswordUtils.generateRandomSalt(); + String saltBase64 = Base64.toBase64String(salt); + + String result = PasswordUtils.formatArgon2Salt(memory, iterations, parallelism, saltBase64); + + return StringUtils.fromString(result); + } catch (Exception e) { + return CryptoUtils.createError("Error occurred while generating Argon2 salt: " + e.getMessage()); + } + } + + // PBKDF2 methods + + /** + * Hash a password using PBKDF2 with custom parameters. + * + * @param password the password to hash + * @param iterations number of iterations + * @param algorithm HMAC algorithm to use (SHA1, SHA256, SHA512) + * @return hashed password string or error + */ + public static Object hashPasswordPBKDF2(BString password, long iterations, BString algorithm) { + try { + String alg = algorithm.getValue(); + + Object validationError = PasswordUtils.validatePBKDF2Iterations(iterations); + if (validationError != null) { + return validationError; + } + + validationError = PasswordUtils.validatePbkdf2Algorithm(alg); + if (validationError != null) { + return validationError; + } + + if (password.getValue().length() == 0) { + return CryptoUtils.createError("Password cannot be empty"); + } + + byte[] salt = PasswordUtils.generateRandomSalt(); + + byte[] hash = generatePBKDF2Hash(password.getValue(), salt, (int) iterations, alg); + + String saltBase64 = Base64.toBase64String(salt); + String hashBase64 = Base64.toBase64String(hash); + + String result = PasswordUtils.formatPBKDF2Hash(alg, iterations, saltBase64, hashBase64); + + return StringUtils.fromString(result); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + return CryptoUtils.createError("Error occurred while hashing password with PBKDF2: " + e.getMessage()); + } catch (RuntimeException e) { + return CryptoUtils.createError("Unexpected error: " + e.getMessage()); + } catch (Exception e) { + return CryptoUtils.createError("Error occurred while hashing password with PBKDF2: " + e.getMessage()); + } + } + + /** + * Verify a password against a PBKDF2 hash. + * + * @param password the password to verify + * @param hashedPassword the hashed password to verify against + * @return true if password matches, false if not, or error if verification fails + */ + public static Object verifyPasswordPBKDF2(BString password, BString hashedPassword) { + try { + String hash = hashedPassword.getValue(); + Pattern pattern = Pattern.compile(Constants.PBKDF2_HASH_PATTERN); + Matcher matcher = pattern.matcher(hash); + + if (!matcher.matches()) { + return CryptoUtils.createError("Invalid PBKDF2 hash format"); + } + + String algorithm = matcher.group(1).toUpperCase(Locale.ROOT); + int iterations = Integer.parseInt(matcher.group(2)); + byte[] salt = Base64.decode(matcher.group(3)); + byte[] originalHash = Base64.decode(matcher.group(4)); + + byte[] newHash = generatePBKDF2Hash(password.getValue(), salt, iterations, algorithm); + + return PasswordUtils.constantTimeArrayEquals(newHash, originalHash); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + return CryptoUtils.createError("Error occurred while verifying password: " + e.getMessage()); + } catch (IllegalArgumentException e) { + return CryptoUtils.createError("Invalid hash format: " + e.getMessage()); + } catch (RuntimeException e) { + return CryptoUtils.createError("Unexpected error: " + e.getMessage()); + } catch (Exception e) { + return CryptoUtils.createError("Error occurred while verifying password: " + e.getMessage()); + } + } + + /** + * Generate a salt string for PBKDF2 with custom parameters. + * + * @param iterations number of iterations + * @param algorithm HMAC algorithm to use (SHA1, SHA256, SHA512) + * @return formatted salt string or error + */ + public static Object generateSaltPBKDF2(long iterations, BString algorithm) { + try { + String alg = algorithm.getValue(); + + Object validationError = PasswordUtils.validatePBKDF2Iterations(iterations); + if (validationError != null) { + return validationError; + } + + validationError = PasswordUtils.validatePbkdf2Algorithm(alg); + if (validationError != null) { + return validationError; + } + + byte[] salt = PasswordUtils.generateRandomSalt(); + String saltBase64 = Base64.toBase64String(salt); + + String result = PasswordUtils.formatPBKDF2Salt(alg, iterations, saltBase64); + + return StringUtils.fromString(result); + } catch (Exception e) { + return CryptoUtils.createError("Error occurred while generating PBKDF2 salt: " + e.getMessage()); + } + } + + /** + * Generate PBKDF2 hash using the given parameters. + * + * @param password password to hash + * @param salt salt to use + * @param iterations number of iterations + * @param algorithm HMAC algorithm to use (SHA1, SHA256, SHA512) + * @return hash byte array + * @throws NoSuchAlgorithmException if algorithm is not available + * @throws InvalidKeySpecException if key specification is invalid + */ + private static byte[] generatePBKDF2Hash(String password, byte[] salt, int iterations, String algorithm) + throws NoSuchAlgorithmException, InvalidKeySpecException { + + String hmacAlgorithm = algorithm.toUpperCase(Locale.ROOT); + String pbkdf2Algorithm; + int keyLength; + + switch (hmacAlgorithm) { + case "SHA1": + pbkdf2Algorithm = "PBKDF2WithHmacSHA1"; + keyLength = 20; // SHA-1 produces 160-bit (20-byte) hash + break; + case "SHA256": + pbkdf2Algorithm = "PBKDF2WithHmacSHA256"; + keyLength = 32; // SHA-256 produces 256-bit (32-byte) hash + break; + case "SHA512": + pbkdf2Algorithm = "PBKDF2WithHmacSHA512"; + keyLength = 64; // SHA-512 produces 512-bit (64-byte) hash + break; + default: + throw new NoSuchAlgorithmException("Unsupported algorithm: " + algorithm); + } + // PBEKeySpec requires keyLength in bits + // and the length of the key in bytes is keyLength / 8 + // so we need to multiply by 8 to get the key length in bits + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, keyLength * 8); + SecretKeyFactory factory = SecretKeyFactory.getInstance(pbkdf2Algorithm); + return factory.generateSecret(spec).getEncoded(); } } diff --git a/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/PasswordArgon2.java b/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/PasswordArgon2.java deleted file mode 100644 index d46479e6..00000000 --- a/native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/PasswordArgon2.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package io.ballerina.stdlib.crypto.nativeimpl; - -import io.ballerina.runtime.api.utils.StringUtils; -import io.ballerina.runtime.api.values.BString; -import io.ballerina.stdlib.crypto.CryptoUtils; -import io.ballerina.stdlib.crypto.PasswordUtils; -import org.bouncycastle.crypto.generators.Argon2BytesGenerator; -import org.bouncycastle.crypto.params.Argon2Parameters; -import org.bouncycastle.util.encoders.Base64; - -import java.nio.charset.StandardCharsets; - -/** - * Native implementation of Argon2 password hashing functions. - * Provides methods for hashing passwords, verifying hashes, and generating salts using the Argon2id algorithm. - * - * @since ... - */ -public class PasswordArgon2 { - - private PasswordArgon2() {} - - /** - * Hash a password using Argon2 with default parameters. - * - * @param password the password to hash - * @return hashed password string or error - */ - public static Object hashPasswordArgon2(BString password) { - return hashPasswordArgon2(password, PasswordUtils.DEFAULT_ITERATIONS, PasswordUtils.DEFAULT_MEMORY, - PasswordUtils.DEFAULT_PARALLELISM); - } - - /** - * Hash a password using Argon2 with custom parameters. - * - * @param password the password to hash - * @param iterations number of iterations - * @param memory memory usage in KB - * @param parallelism number of parallel threads - * @return hashed password string or error - */ - public static Object hashPasswordArgon2(BString password, long iterations, long memory, long parallelism) { - try { - if (iterations <= 0) { - return CryptoUtils.createError("Iterations must be positive"); - } - if (memory < 8192) { - return CryptoUtils.createError("Memory must be at least 8192 KB (8MB)"); - } - if (parallelism <= 0) { - return CryptoUtils.createError("Parallelism must be positive"); - } - if (password.getValue().length() == 0) { - return CryptoUtils.createError("Password cannot be empty"); - } - - byte[] salt = PasswordUtils.generateRandomSalt(); - - Argon2Parameters params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) - .withSalt(salt) - .withIterations((int) iterations) - .withMemoryAsKB((int) memory) - .withParallelism((int) parallelism) - .build(); - - byte[] hash = new byte[PasswordUtils.HASH_LENGTH]; - Argon2BytesGenerator generator = new Argon2BytesGenerator(); - generator.init(params); - generator.generateBytes(password.getValue().getBytes(StandardCharsets.UTF_8), hash); - - String saltBase64 = Base64.toBase64String(salt); - String hashBase64 = Base64.toBase64String(hash); - - String result = PasswordUtils.formatArgon2Hash(memory, iterations, parallelism, saltBase64, hashBase64); - - return StringUtils.fromString(result); - } catch (Exception e) { - return CryptoUtils.createError("Error occurred while hashing password with Argon2: " + e.getMessage()); - } - } - - /** - * Verify a password against an Argon2 hash. - * - * @param password the password to verify - * @param hashedPassword the hashed password to verify against - * @return true if password matches, false if not, or error if verification fails - */ - public static Object verifyPasswordArgon2(BString password, BString hashedPassword) { - try { - String hash = hashedPassword.getValue(); - if (!hash.startsWith("$argon2id$")) { - return CryptoUtils.createError("Invalid Argon2 hash format"); - } - - String[] parts = hash.split("\\$"); - if (parts.length != 6) { - return CryptoUtils.createError("Invalid Argon2 hash format"); - } - - String[] params = parts[3].split(","); - int memory = Integer.parseInt(params[0].substring(2)); - int iterations = Integer.parseInt(params[1].substring(2)); - int parallelism = Integer.parseInt(params[2].substring(2)); - - byte[] salt = Base64.decode(parts[4]); - byte[] originalHash = Base64.decode(parts[5]); - - Argon2Parameters parameters = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) - .withSalt(salt) - .withIterations(iterations) - .withMemoryAsKB(memory) - .withParallelism(parallelism) - .build(); - - byte[] newHash = new byte[PasswordUtils.HASH_LENGTH]; - Argon2BytesGenerator generator = new Argon2BytesGenerator(); - generator.init(parameters); - generator.generateBytes(password.getValue().getBytes(StandardCharsets.UTF_8), newHash); - - return PasswordUtils.constantTimeArrayEquals(newHash, originalHash); - } catch (Exception e) { - return CryptoUtils.createError("Error occurred while verifying password: " + e.getMessage()); - } - } - - /** - * Generate a salt string for Argon2 with default parameters. - * - * @return formatted salt string or error - */ - public static Object generateSaltArgon2() { - return generateSaltArgon2(PasswordUtils.DEFAULT_ITERATIONS, PasswordUtils.DEFAULT_MEMORY, - PasswordUtils.DEFAULT_PARALLELISM); - } - - /** - * Generate a salt string for Argon2 with custom parameters. - * - * @param iterations number of iterations - * @param memory memory usage in KB - * @param parallelism number of parallel threads - * @return formatted salt string or error - */ - public static Object generateSaltArgon2(long iterations, long memory, long parallelism) { - try { - byte[] salt = PasswordUtils.generateRandomSalt(); - String saltBase64 = Base64.toBase64String(salt); - - String result = PasswordUtils.formatArgon2Salt(memory, iterations, parallelism, saltBase64); - - return StringUtils.fromString(result); - } catch (Exception e) { - return CryptoUtils.createError("Error occurred while generating Argon2 salt: " + e.getMessage()); - } - } -}