-
Notifications
You must be signed in to change notification settings - Fork 36
Add BCrypt and Argon2 password handling to crypto module #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
daneshk
merged 42 commits into
ballerina-platform:master
from
randilt:implement-password-handling-bcrypt
Jan 20, 2025
Merged
Changes from 15 commits
Commits
Show all changes
42 commits
Select commit
Hold shift + click to select a range
c25a395
[Automated] Update the native jar versions
randilt 0714c09
Implement a password handling library using BCrypt
randilt 296d158
Merge branch 'ballerina-platform:master' into implement-password-hand…
randilt 0dee40b
Add Argon2 password hashing and verification functions with tests
randilt 408b7ef
Add parameter validation to hashPasswordArgon2 and update testcase
randilt 1824e22
Merge branch 'implement-password-handling-bcrypt' of https://github.c…
randilt 62c3d0c
Add PasswordUtils class for password handling and validation functions
randilt a44a579
[Automated] Update the native jar versions
randilt 5c44829
Refactor PasswordArgon2 to utilize PasswordUtils for salt generation …
randilt b6dd7b3
Add tests for password hash uniqueness and remove unused constant tim…
randilt e295de0
Remove debug print statements from password hashing tests and add not…
randilt 8e6248f
Add copyright headers and improve documentation for password hashing …
randilt 8262eae
Move all password handling functions to password.bal
randilt 89d0995
Refactor PasswordArgon2 to use constants from PasswordUtils for Argon…
randilt ba7a1d5
Update license headers and remove .vscode folder
randilt d6c2a4e
Update native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Pas…
randilt f8a9cbe
Update native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Pas…
randilt e737920
Update native/src/main/java/io/ballerina/stdlib/crypto/PasswordUtils.…
randilt 1e6a247
Apply suggestions from code review
randilt 7626a78
Rename password hashing functions for clarity and update related tests
randilt aa04351
Rename password hashing functions in tests for consistency and clarity
randilt f1fce08
Add validation for empty passwords in Password and PasswordArgon2 cla…
randilt d939829
Add documentation for password hashing using BCrypt and Argon2 algori…
randilt 2064262
Enhance documentation for password hashing algorithms, including deta…
randilt 9c6d497
Update documentation links for password hashing section and algorithms
randilt 927c46c
Update ballerina/tests/password_argon2_test.bal
randilt 9bd44a9
Remove obsolete Argon2 test file to streamline test suite
randilt 379bf97
Update changelog to include new APIs for password hashing and verific…
randilt 9afed2b
Remove unreleased version from changelog
randilt 8e5cc4b
Move BCrypt and Argon2 password hashing and verification functions to…
randilt 5658f3e
Apply suggestions from code review
randilt aef45d0
Update changelog.md
randilt 8249072
Implement suggested security fixes and use Locale.ROOT in String.form…
randilt 20bbda9
Refactor constantTimeArrayEquals to use MessageDigest.isEqual from st…
randilt 1569a4c
Add proposal for BCrypt and Argon2id password hashing support in Ball…
randilt c4793f3
Enhance password hashing proposal with future additions and API enhan…
randilt 4c74cbf
Clarify future additions proposal for bcrypt and Argon2id hashing API…
randilt ce5419f
Update proposal document for bcrypt and Argon2id hashing APIs to incl…
randilt ea9fc44
Update bcrypt and Argon2id hashing proposal to include reviewer detai…
randilt f7e61af
Update bcrypt and Argon2id hashing proposal to include additional iss…
randilt cb75962
Update bcrypt and Argon2id hashing proposal to include reviewer infor…
randilt 7becdb6
Update docs/proposals/bcrypt-argon2id-hashing-apis.md
randilt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| // 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. | ||
|
|
||
| import ballerina/jballerina.java; | ||
|
|
||
| # Returns a BCrypt hash of the given password with optional work factor. | ||
| # ```ballerina | ||
| # string password = "mySecurePassword123"; | ||
| # string|crypto:Error hash = crypto:hashPassword(password); | ||
| # ``` | ||
| # | ||
| # + password - Password string to be hashed | ||
| # + workFactor - Optional work factor (cost parameter) between 4 and 31. Default is 12 | ||
| # + return - BCrypt hashed password string or Error if hashing fails | ||
| public isolated function hashPassword(string password, int workFactor = 12) returns string|Error = @java:Method { | ||
| name: "hashPassword", | ||
| 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" | ||
| } external; | ||
|
|
||
| # Verifies if a password matches a BCrypt hashed password. | ||
| # ```ballerina | ||
| # string password = "mySecurePassword123"; | ||
| # string hashedPassword = "$2a$12$LQV3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewYpwBAM7RHF.H9m"; | ||
| # boolean|crypto:Error matches = crypto:verifyPassword(password, hashedPassword); | ||
| # ``` | ||
| # | ||
| # + password - Password string to verify | ||
| # + hashedPassword - BCrypt hashed password to verify against | ||
| # + return - Boolean indicating if password matches or Error if verification fails | ||
| public isolated function verifyPassword(string password, string hashedPassword) returns boolean|Error = @java:Method { | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| name: "verifyPassword", | ||
| 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" | ||
| } external; | ||
|
|
||
| # Generates a BCrypt salt with optional work factor. | ||
| # ```ballerina | ||
| # string|crypto:Error salt = crypto:generateSalt(14); | ||
| # ``` | ||
| # | ||
| # + workFactor - Optional work factor (cost parameter) between 4 and 31. Default is 12 | ||
| # + return - Generated BCrypt salt string or Error if generation fails | ||
| public isolated function generateSalt(int workFactor = 12) returns string|Error = @java:Method { | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| name: "generateSalt", | ||
| 'class: "io.ballerina.stdlib.crypto.nativeimpl.Password" | ||
| } external; | ||
|
|
||
| # Returns an Argon2id hash of the given password with optional parameters. | ||
| # ```ballerina | ||
| # string password = "mySecurePassword123"; | ||
| # string|crypto:Error hash = crypto:hashPasswordArgon2(password); | ||
| # ``` | ||
| # | ||
| # + password - Password string to be hashed | ||
| # + iterations - Optional number of iterations. Default is 3 | ||
| # + memory - Optional memory usage in KB. Default is 65536 (64MB) | ||
| # + parallelism - Optional degree of parallelism. Default is 4 | ||
| # + return - Argon2id hashed password string or Error if hashing fails | ||
| public isolated function hashPasswordArgon2(string password, int iterations = 3, int memory = 65536, int parallelism = 4) returns string|Error = @java:Method { | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| name: "hashPasswordArgon2", | ||
| 'class: "io.ballerina.stdlib.crypto.nativeimpl.PasswordArgon2" | ||
| } external; | ||
|
|
||
| # Verifies if a password matches an Argon2id hashed password. | ||
| # ```ballerina | ||
| # string password = "mySecurePassword123"; | ||
| # string hashedPassword = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$hash"; | ||
| # boolean|crypto:Error matches = crypto:verifyPasswordArgon2(password, hashedPassword); | ||
| # ``` | ||
| # | ||
| # + password - Password string to verify | ||
| # + hashedPassword - Argon2id hashed password to verify against | ||
| # + return - Boolean indicating if password matches or Error if verification fails | ||
| public isolated function verifyPasswordArgon2(string password, string hashedPassword) returns boolean|Error = @java:Method { | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| name: "verifyPasswordArgon2", | ||
| 'class: "io.ballerina.stdlib.crypto.nativeimpl.PasswordArgon2" | ||
| } external; | ||
|
|
||
| # Generates an Argon2id salt with optional parameters. | ||
| # ```ballerina | ||
| # string|crypto:Error salt = crypto:generateSaltArgon2(4, 131072, 8); | ||
| # ``` | ||
| # | ||
| # + iterations - Optional number of iterations. Default is 3 | ||
| # + memory - Optional memory usage in KB. Default is 65536 (64MB) | ||
| # + parallelism - Optional degree of parallelism. Default is 4 | ||
| # + return - Generated Argon2id salt string or Error if generation fails | ||
| public isolated function generateSaltArgon2(int iterations = 3, int memory = 65536, int parallelism = 4) returns string|Error = @java:Method { | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| name: "generateSaltArgon2", | ||
| 'class: "io.ballerina.stdlib.crypto.nativeimpl.PasswordArgon2" | ||
| } external; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| // 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. | ||
|
|
||
| import ballerina/test; | ||
|
|
||
| @test:Config {} | ||
| isolated function testHashPasswordArgon2Default() { | ||
| string password = "Ballerina@123"; | ||
| string|Error hash = hashPasswordArgon2(password); | ||
| if hash is string { | ||
| test:assertTrue(hash.startsWith("$argon2id$v=19$")); | ||
| test:assertTrue(hash.length() > 50); | ||
| } else { | ||
| test:assertFail("Password hashing failed"); | ||
| } | ||
| } | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| @test:Config {} | ||
| isolated function testHashPasswordArgon2Custom() { | ||
| string password = "Ballerina@123"; | ||
| string|Error hash = hashPasswordArgon2(password, 4, 131072, 8); | ||
| if hash is string { | ||
| test:assertTrue(hash.includes("m=131072,t=4,p=8")); | ||
| test:assertTrue(hash.length() > 50); | ||
| } else { | ||
| test:assertFail("Password hashing failed"); | ||
| } | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testHashPasswordArgon2ComplexPasswords() { | ||
| string[] passwords = [ | ||
| "Short1!", | ||
| "ThisIsAVeryLongPasswordWith123!@#", | ||
| "❤️🌟🎉Pass123!", | ||
| "Pass\u{0000}word123", | ||
| " LeadingSpace123!", | ||
| "TrailingSpace123! ", | ||
| "Pass word123!", | ||
| "!@#$%^&*()_+-=[]{}|;:,.<>?", | ||
| "12345678901234567890", | ||
| "ABCDEFGHIJKLMNOPQRSTUVWXYZ", | ||
| "abcdefghijklmnopqrstuvwxyz" | ||
| ]; | ||
|
|
||
| foreach string password in passwords { | ||
| string|Error hash = hashPasswordArgon2(password); | ||
| if hash is string { | ||
| test:assertTrue(hash.startsWith("$argon2id$v=19$")); | ||
|
|
||
| boolean|Error result = verifyPasswordArgon2(password, hash); | ||
| if result is boolean { | ||
| test:assertTrue(result, "Password verification failed for: " + password); | ||
| } else { | ||
| test:assertFail("Verification error for password: " + password); | ||
| } | ||
| } else { | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| test:assertFail("Hashing failed for password: " + password); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testHashPasswordArgon2InvalidParams() { | ||
| string password = "Ballerina@123"; | ||
| record {|int[] params; string expectedError;|}[] testCases = [ | ||
| {params: [0, 65536, 4], expectedError: "Iterations must be positive"}, | ||
| {params: [3, 1024, 4], expectedError: "Memory must be at least 8192 KB (8MB)"}, | ||
| {params: [3, 65536, 0], expectedError: "Parallelism must be positive"}, | ||
| {params: [-1, 65536, 4], expectedError: "Iterations must be positive"}, | ||
| {params: [3, -1024, 4], expectedError: "Memory must be at least 8192 KB (8MB)"}, | ||
| {params: [3, 65536, -2], expectedError: "Parallelism must be positive"} | ||
| ]; | ||
|
|
||
| foreach var {params, expectedError} in testCases { | ||
| string|Error hash = hashPasswordArgon2(password, params[0], params[1], params[2]); | ||
| if hash is Error { | ||
| test:assertEquals(hash.message(), expectedError); | ||
| } else { | ||
| test:assertFail(string `Should fail with invalid parameters: ${params.toString()}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testVerifyPasswordArgon2Success() { | ||
| string[] passwords = [ | ||
| "Ballerina@123", | ||
| "AnotherPass@456", | ||
| "YetAnotherPass@789", | ||
| "❤️🌟🎉Pass123!", | ||
| "Helloasdjk@123#999xDhabasdas333" | ||
| ]; | ||
|
|
||
| foreach string password in passwords { | ||
| string|Error hash = hashPasswordArgon2(password); | ||
| if hash is string { | ||
| boolean|Error result = verifyPasswordArgon2(password, hash); | ||
| if result is boolean { | ||
| test:assertTrue(result, "Password verification failed for: " + password); | ||
| } else { | ||
| test:assertFail("Password verification error for: " + password); | ||
| } | ||
randilt marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } else { | ||
| test:assertFail("Password hashing failed for: " + password); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testVerifyPasswordArgon2Failure() { | ||
| string password = "Ballerina@123"; | ||
| string[] wrongPasswords = [ | ||
| "ballerina@123", | ||
| "Ballerina@124", | ||
| "Ballerina@1234", | ||
| "Ballerin@123", | ||
| " Ballerina@123", | ||
| "Ballerina@123 ", | ||
| "" | ||
| ]; | ||
|
|
||
| string|Error hash = hashPasswordArgon2(password); | ||
| if hash is string { | ||
| foreach string wrongPassword in wrongPasswords { | ||
| boolean|Error result = verifyPasswordArgon2(wrongPassword, hash); | ||
| if result is boolean { | ||
| test:assertFalse(result, "Should fail for wrong password: " + wrongPassword); | ||
| } else { | ||
| test:assertFail("Verification error for wrong password: " + wrongPassword); | ||
| } | ||
| } | ||
| } else { | ||
| test:assertFail("Password hashing failed"); | ||
| } | ||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testVerifyPasswordArgon2InvalidHashFormat() { | ||
| string password = "Ballerina@123"; | ||
| string[] invalidHashes = [ | ||
| "invalid_hash_format", | ||
| "$argon2id$v=19$invalid", | ||
| "$argon2id$v=19$m=65536$missing_parts", | ||
| "$argon2i$v=19$m=65536,t=3,p=4$salt$hash" // Wrong variant | ||
| ]; | ||
|
|
||
| foreach string invalidHash in invalidHashes { | ||
| boolean|Error result = verifyPasswordArgon2(password, invalidHash); | ||
| if result is Error { | ||
| test:assertTrue(result.message().startsWith("Invalid Argon2 hash format")); | ||
| } else { | ||
| test:assertFail("Should fail with invalid hash: " + invalidHash); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testGenerateSaltArgon2Default() { | ||
| string|Error salt = generateSaltArgon2(); | ||
| if salt is string { | ||
| test:assertTrue(salt.startsWith("$argon2id$v=19$")); | ||
| test:assertTrue(salt.includes("m=65536,t=3,p=4")); | ||
| } else { | ||
| test:assertFail("Salt generation failed"); | ||
| } | ||
| } | ||
|
|
||
| @test:Config {} | ||
| isolated function testGenerateSaltArgon2Custom() { | ||
| int[][] validParams = [ | ||
| [4, 131072, 8], | ||
| [2, 65536, 4], | ||
| [6, 262144, 16] | ||
| ]; | ||
|
|
||
| foreach int[] params in validParams { | ||
| string|Error salt = generateSaltArgon2(params[0], params[1], params[2]); | ||
| if salt is string { | ||
| string expectedParams = string `m=${params[1]},t=${params[0]},p=${params[2]}`; | ||
| test:assertTrue(salt.includes(expectedParams)); | ||
| } else { | ||
| test:assertFail("Salt generation failed for params: " + params.toString()); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Note: The below test case verifies that hashing the same password multiple times | ||
| // produces different results due to the use of random salts. However, there is | ||
| // an extremely rare chance of this test failing if the random salts generated | ||
| // happen to match. The probability of such a collision is approximately 1 in 2^128 | ||
| // (based on the randomness of a 128-bit salt). | ||
| // | ||
| // In practice, this is highly unlikely and should not occur under normal circumstances. | ||
| @test:Config {} | ||
| isolated function testArgon2PasswordHashUniqueness() { | ||
| string[] passwords = [ | ||
| "Ballerina@123", | ||
| "Complex!Pass#2024", | ||
| "Test123!@#", | ||
| "❤️SecurePass789", | ||
| "LongPassword123!@#$" | ||
| ]; | ||
|
|
||
| foreach string password in passwords { | ||
| // Generate three hashes for the same password | ||
| string|Error hash1 = hashPasswordArgon2(password); | ||
| string|Error hash2 = hashPasswordArgon2(password); | ||
| string|Error hash3 = hashPasswordArgon2(password); | ||
|
|
||
| if (hash1 is string && hash2 is string && hash3 is string) { | ||
| // Verify all hashes are different | ||
| test:assertNotEquals(hash1, hash2, "Hashes should be unique for: " + password); | ||
| test:assertNotEquals(hash2, hash3, "Hashes should be unique for: " + password); | ||
| test:assertNotEquals(hash1, hash3, "Hashes should be unique for: " + password); | ||
|
|
||
| // Verify all hashes are valid for the password | ||
| boolean|Error verify1 = verifyPasswordArgon2(password, hash1); | ||
| boolean|Error verify2 = verifyPasswordArgon2(password, hash2); | ||
| boolean|Error verify3 = verifyPasswordArgon2(password, hash3); | ||
|
|
||
| if (verify1 is boolean && verify2 is boolean && verify3 is boolean) { | ||
| test:assertTrue(verify1 && verify2 && verify3, | ||
| "All hashes should verify successfully for: " + password); | ||
| } else { | ||
| test:assertFail("Verification failed for: " + password); | ||
| } | ||
| } else { | ||
| test:assertFail("Hash generation failed for: " + password); | ||
| } | ||
| } | ||
| } | ||
MohamedSabthar marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.