Skip to content
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

Add BCrypt and Argon2 password handling to crypto module #577

Merged
Merged
Show file tree
Hide file tree
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 Jan 9, 2025
0714c09
Implement a password handling library using BCrypt
randilt Jan 9, 2025
296d158
Merge branch 'ballerina-platform:master' into implement-password-hand…
randilt Jan 9, 2025
0dee40b
Add Argon2 password hashing and verification functions with tests
randilt Jan 9, 2025
408b7ef
Add parameter validation to hashPasswordArgon2 and update testcase
randilt Jan 9, 2025
1824e22
Merge branch 'implement-password-handling-bcrypt' of https://github.c…
randilt Jan 9, 2025
62c3d0c
Add PasswordUtils class for password handling and validation functions
randilt Jan 9, 2025
a44a579
[Automated] Update the native jar versions
randilt Jan 9, 2025
5c44829
Refactor PasswordArgon2 to utilize PasswordUtils for salt generation …
randilt Jan 9, 2025
b6dd7b3
Add tests for password hash uniqueness and remove unused constant tim…
randilt Jan 10, 2025
e295de0
Remove debug print statements from password hashing tests and add not…
randilt Jan 10, 2025
8e6248f
Add copyright headers and improve documentation for password hashing …
randilt Jan 10, 2025
8262eae
Move all password handling functions to password.bal
randilt Jan 10, 2025
89d0995
Refactor PasswordArgon2 to use constants from PasswordUtils for Argon…
randilt Jan 10, 2025
ba7a1d5
Update license headers and remove .vscode folder
randilt Jan 13, 2025
d6c2a4e
Update native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Pas…
randilt Jan 14, 2025
f8a9cbe
Update native/src/main/java/io/ballerina/stdlib/crypto/nativeimpl/Pas…
randilt Jan 14, 2025
e737920
Update native/src/main/java/io/ballerina/stdlib/crypto/PasswordUtils.…
randilt Jan 14, 2025
1e6a247
Apply suggestions from code review
randilt Jan 16, 2025
7626a78
Rename password hashing functions for clarity and update related tests
randilt Jan 16, 2025
aa04351
Rename password hashing functions in tests for consistency and clarity
randilt Jan 16, 2025
f1fce08
Add validation for empty passwords in Password and PasswordArgon2 cla…
randilt Jan 16, 2025
d939829
Add documentation for password hashing using BCrypt and Argon2 algori…
randilt Jan 16, 2025
2064262
Enhance documentation for password hashing algorithms, including deta…
randilt Jan 16, 2025
9c6d497
Update documentation links for password hashing section and algorithms
randilt Jan 16, 2025
927c46c
Update ballerina/tests/password_argon2_test.bal
randilt Jan 16, 2025
9bd44a9
Remove obsolete Argon2 test file to streamline test suite
randilt Jan 16, 2025
379bf97
Update changelog to include new APIs for password hashing and verific…
randilt Jan 16, 2025
9afed2b
Remove unreleased version from changelog
randilt Jan 16, 2025
8e5cc4b
Move BCrypt and Argon2 password hashing and verification functions to…
randilt Jan 17, 2025
5658f3e
Apply suggestions from code review
randilt Jan 17, 2025
aef45d0
Update changelog.md
randilt Jan 17, 2025
8249072
Implement suggested security fixes and use Locale.ROOT in String.form…
randilt Jan 19, 2025
20bbda9
Refactor constantTimeArrayEquals to use MessageDigest.isEqual from st…
randilt Jan 19, 2025
1569a4c
Add proposal for BCrypt and Argon2id password hashing support in Ball…
randilt Jan 20, 2025
c4793f3
Enhance password hashing proposal with future additions and API enhan…
randilt Jan 20, 2025
4c74cbf
Clarify future additions proposal for bcrypt and Argon2id hashing API…
randilt Jan 20, 2025
ce5419f
Update proposal document for bcrypt and Argon2id hashing APIs to incl…
randilt Jan 20, 2025
ea9fc44
Update bcrypt and Argon2id hashing proposal to include reviewer detai…
randilt Jan 20, 2025
f7e61af
Update bcrypt and Argon2id hashing proposal to include additional iss…
randilt Jan 20, 2025
cb75962
Update bcrypt and Argon2id hashing proposal to include reviewer infor…
randilt Jan 20, 2025
7becdb6
Update docs/proposals/bcrypt-argon2id-hashing-apis.md
randilt Jan 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ballerina/Dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

[ballerina]
dependencies-toml-version = "2"
distribution-version = "2201.11.0-20241121-075100-c4c87cbc"
distribution-version = "2201.11.0-20241218-101200-109f6cc7"

[[package]]
org = "ballerina"
Expand Down
103 changes: 103 additions & 0 deletions ballerina/password.bal
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 {
randilt marked this conversation as resolved.
Show resolved Hide resolved
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.
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.
Show resolved Hide resolved
randilt marked this conversation as resolved.
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.
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.
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.
Show resolved Hide resolved
name: "generateSaltArgon2",
'class: "io.ballerina.stdlib.crypto.nativeimpl.PasswordArgon2"
} external;
245 changes: 245 additions & 0 deletions ballerina/tests/password_argon2_test.bal
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.
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.
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.
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.
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.
Show resolved Hide resolved
Loading
Loading