blob: dcc8b5f5fa1b0d133d89b052b2f71b0d59ff50f0 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF 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 org.apache.nifi.security.util.scrypt
import org.apache.commons.codec.binary.Hex
import org.apache.nifi.security.util.crypto.scrypt.Scrypt
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.condition.EnabledIfSystemProperty
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.security.SecureRandom
import java.security.Security
import static groovy.test.GroovyAssert.shouldFail
import static org.junit.jupiter.api.Assumptions.assumeTrue
class ScryptGroovyTest {
private static final Logger logger = LoggerFactory.getLogger(ScryptGroovyTest.class)
private static final String PASSWORD = "shortPassword"
private static final String SALT_HEX = "0123456789ABCDEFFEDCBA9876543210"
private static final byte[] SALT_BYTES = Hex.decodeHex(SALT_HEX as char[])
// Small values to test for correctness, not timing
private static final int N = 2**4
private static final int R = 1
private static final int P = 1
private static final int DK_LEN = 128
private static final long TWO_GIGABYTES = 2048L * 1024 * 1024
@BeforeAll
static void setUpOnce() throws Exception {
Security.addProvider(new BouncyCastleProvider())
logger.metaClass.methodMissing = { String name, args ->
logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
}
}
@Test
void testDeriveScryptKeyShouldBeInternallyConsistent() throws Exception {
// Arrange
def allKeys = []
final int RUNS = 10
logger.info("Running with '${PASSWORD}', '${SALT_HEX}', $N, $R, $P, $DK_LEN")
// Act
RUNS.times {
byte[] keyBytes = Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, N, R, P, DK_LEN)
logger.info("Derived key: ${Hex.encodeHexString(keyBytes)}")
allKeys << keyBytes
}
// Assert
assert allKeys.size() == RUNS
assert allKeys.every { it == allKeys.first() }
}
/**
* This test ensures that the local implementation of Scrypt is compatible with the reference implementation from the Colin Percival paper.
*/
@Test
void testDeriveScryptKeyShouldMatchTestVectors() {
// Arrange
// These values are taken from Colin Percival's scrypt paper: https://www.tarsnap.com/scrypt/scrypt.pdf
final byte[] HASH_2 = Hex.decodeHex("fdbabe1c9d3472007856e7190d01e9fe" +
"7c6ad7cbc8237830e77376634b373162" +
"2eaf30d92e22a3886ff109279d9830da" +
"c727afb94a83ee6d8360cbdfa2cc0640" as char[])
final byte[] HASH_3 = Hex.decodeHex("7023bdcb3afd7348461c06cd81fd38eb" +
"fda8fbba904f8e3ea9b543f6545da1f2" +
"d5432955613f0fcf62d49705242a9af9" +
"e61e85dc0d651e40dfcf017b45575887" as char[])
final def TEST_VECTORS = [
// Empty password is not supported by JCE
[password: "password",
salt : "NaCl",
n : 1024,
r : 8,
p : 16,
dkLen : 64 * 8,
hash : HASH_2],
[password: "pleaseletmein",
salt : "SodiumChloride",
n : 16384,
r : 8,
p : 1,
dkLen : 64 * 8,
hash : HASH_3],
]
// Act
TEST_VECTORS.each { Map params ->
logger.info("Running with '${params.password}', '${params.salt}', ${params.n}, ${params.r}, ${params.p}, ${params.dkLen}")
long memoryInBytes = Scrypt.calculateExpectedMemory(params.n, params.r, params.p)
logger.info("Expected memory usage: (128 * r * N + 128 * r * p) ${memoryInBytes} bytes")
logger.info(" Expected ${Hex.encodeHexString(params.hash)}")
byte[] calculatedHash = Scrypt.deriveScryptKey(params.password.bytes, params.salt.bytes, params.n, params.r, params.p, params.dkLen)
logger.info("Generated ${Hex.encodeHexString(calculatedHash)}")
// Assert
assert calculatedHash == params.hash
}
}
/**
* This test ensures that the local implementation of Scrypt is compatible with the reference implementation from the Colin Percival paper. The test vector requires ~1GB {@code byte[]}
* and therefore the Java heap must be at least 1GB. Because {@link nifi/pom.xml} has a {@code surefire} rule which appends {@code -Xmx1G}
* to the Java options, this overrides any IDE options. To ensure the heap is properly set, using the {@code groovyUnitTest} profile will re-append {@code -Xmx3072m} to the Java options.
*/
@Test
void testDeriveScryptKeyShouldMatchExpensiveTestVector() {
// Arrange
long totalMemory = Runtime.getRuntime().totalMemory()
logger.info("Required memory: ${TWO_GIGABYTES} bytes")
logger.info("Max heap memory: ${totalMemory} bytes")
assumeTrue(totalMemory >= TWO_GIGABYTES, "Test is being skipped due to JVM heap size. Please run with -Xmx3072m to set sufficient heap size")
// These values are taken from Colin Percival's scrypt paper: https://www.tarsnap.com/scrypt/scrypt.pdf
final byte[] HASH = Hex.decodeHex("2101cb9b6a511aaeaddbbe09cf70f881" +
"ec568d574a2ffd4dabe5ee9820adaa47" +
"8e56fd8f4ba5d09ffa1c6d927c40f4c3" +
"37304049e8a952fbcbf45c6fa77a41a4" as char[])
// This test vector requires 2GB heap space and approximately 10 seconds on a consumer machine
String password = "pleaseletmein"
String salt = "SodiumChloride"
int n = 1048576
int r = 8
int p = 1
int dkLen = 64 * 8
// Act
logger.info("Running with '${password}', '${salt}', ${n}, ${r}, ${p}, ${dkLen}")
long memoryInBytes = Scrypt.calculateExpectedMemory(n, r, p)
logger.info("Expected memory usage: (128 * r * N + 128 * r * p) ${memoryInBytes} bytes")
logger.info(" Expected ${Hex.encodeHexString(HASH)}")
byte[] calculatedHash = Scrypt.deriveScryptKey(password.bytes, salt.bytes, n, r, p, dkLen)
logger.info("Generated ${Hex.encodeHexString(calculatedHash)}")
// Assert
assert calculatedHash == HASH
}
@EnabledIfSystemProperty(named = "nifi.test.unstable", matches = "true")
@Test
void testShouldCauseOutOfMemoryError() {
SecureRandom secureRandom = new SecureRandom()
// int i = 29
(10..31).each { int i ->
int length = 2**i
byte[] bytes = new byte[length]
secureRandom.nextBytes(bytes)
logger.info("Successfully ran with byte[] of length ${length}")
logger.info("${Hex.encodeHexString(bytes[0..<16] as byte[])}...")
}
}
@Test
void testDeriveScryptKeyShouldSupportExternalCompatibility() {
// Arrange
// These values can be generated by running `$ ./openssl_scrypt.rb` in the terminal
final String EXPECTED_KEY_HEX = "a8efbc0a709d3f89b6bb35b05fc8edf5"
String password = "thisIsABadPassword"
String saltHex = "f5b8056ea6e66edb8d013ac432aba24a"
int n = 1024
int r = 8
int p = 36
int dkLen = 16 * 8
// Act
logger.info("Running with '${password}', ${saltHex}, ${n}, ${r}, ${p}, ${dkLen}")
long memoryInBytes = Scrypt.calculateExpectedMemory(n, r, p)
logger.info("Expected memory usage: (128 * r * N + 128 * r * p) ${memoryInBytes} bytes")
logger.info(" Expected ${EXPECTED_KEY_HEX}")
byte[] calculatedHash = Scrypt.deriveScryptKey(password.bytes, Hex.decodeHex(saltHex as char[]), n, r, p, dkLen)
logger.info("Generated ${Hex.encodeHexString(calculatedHash)}")
// Assert
assert calculatedHash == Hex.decodeHex(EXPECTED_KEY_HEX as char[])
}
@Test
void testScryptShouldBeInternallyConsistent() throws Exception {
// Arrange
def allHashes = []
final int RUNS = 10
logger.info("Running with '${PASSWORD}', '${SALT_HEX}', $N, $R, $P")
// Act
RUNS.times {
String hash = Scrypt.scrypt(PASSWORD, SALT_BYTES, N, R, P, DK_LEN)
logger.info("Hash: ${hash}")
allHashes << hash
}
// Assert
assert allHashes.size() == RUNS
assert allHashes.every { it == allHashes.first() }
}
@Test
void testScryptShouldGenerateValidSaltIfMissing() {
// Arrange
// The generated salt should be byte[16], encoded as 22 Base64 chars
final def EXPECTED_SALT_PATTERN = /\$.+\$[0-9a-zA-Z\/\+]{22}\$.+/
// Act
String calculatedHash = Scrypt.scrypt(PASSWORD, N, R, P, DK_LEN)
logger.info("Generated ${calculatedHash}")
// Assert
assert calculatedHash =~ EXPECTED_SALT_PATTERN
}
@Test
void testScryptShouldNotAcceptInvalidN() throws Exception {
// Arrange
final int MAX_N = Integer.MAX_VALUE / 128 / R - 1
// N must be a power of 2 > 1 and < Integer.MAX_VALUE / 128 / r
final def INVALID_NS = [-2, 0, 1, 3, 4096 - 1, MAX_N + 1]
// Act
INVALID_NS.each { int invalidN ->
logger.info("Using N: ${invalidN}")
def msg = shouldFail(IllegalArgumentException) {
Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, invalidN, R, P, DK_LEN)
}
// Assert
assert msg =~ "N must be a power of 2 greater than 1|Parameter N is too large"
}
}
@Test
void testScryptShouldAcceptValidR() throws Exception {
// Arrange
// Use a large p value to allow r to exceed MAX_R without normal N exceeding MAX_N
int largeP = 2**10
final int MAX_R = Math.ceil(Integer.MAX_VALUE / 128 / largeP) - 1
// r must be in (0..Integer.MAX_VALUE / 128 / p)
final def INVALID_RS = [0, MAX_R + 1]
// Act
INVALID_RS.each { int invalidR ->
logger.info("Using r: ${invalidR}")
def msg = shouldFail(IllegalArgumentException) {
byte[] hash = Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, N, invalidR, largeP, DK_LEN)
logger.info("Generated hash: ${Hex.encodeHexString(hash)}")
}
// Assert
assert msg =~ "Parameter r must be 1 or greater|Parameter r is too large"
}
}
@Test
void testScryptShouldNotAcceptInvalidP() throws Exception {
// Arrange
final int MAX_P = Math.ceil(Integer.MAX_VALUE / 128) - 1
// p must be in (0..Integer.MAX_VALUE / 128)
final def INVALID_PS = [0, MAX_P + 1]
// Act
INVALID_PS.each { int invalidP ->
logger.info("Using p: ${invalidP}")
def msg = shouldFail(IllegalArgumentException) {
byte[] hash = Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, N, R, invalidP, DK_LEN)
logger.info("Generated hash: ${Hex.encodeHexString(hash)}")
}
// Assert
assert msg =~ "Parameter p must be 1 or greater|Parameter p is too large"
}
}
@Test
void testCheckShouldValidateCorrectPassword() throws Exception {
// Arrange
final String PASSWORD = "thisIsABadPassword"
final String EXPECTED_HASH = Scrypt.scrypt(PASSWORD, N, R, P, DK_LEN)
logger.info("Password: ${PASSWORD} -> Hash: ${EXPECTED_HASH}")
// Act
boolean matches = Scrypt.check(PASSWORD, EXPECTED_HASH)
logger.info("Check matches: ${matches}")
// Assert
assert matches
}
@Test
void testCheckShouldNotValidateIncorrectPassword() throws Exception {
// Arrange
final String PASSWORD = "thisIsABadPassword"
final String EXPECTED_HASH = Scrypt.scrypt(PASSWORD, N, R, P, DK_LEN)
logger.info("Password: ${PASSWORD} -> Hash: ${EXPECTED_HASH}")
// Act
boolean matches = Scrypt.check(PASSWORD.reverse(), EXPECTED_HASH)
logger.info("Check matches: ${matches}")
// Assert
assert !matches
}
@Test
void testCheckShouldNotAcceptInvalidPassword() throws Exception {
// Arrange
final String HASH = '$s0$a0801$abcdefghijklmnopqrstuv$abcdefghijklmnopqrstuv'
// Even though the spec allows for empty passwords, the JCE does not, so extend enforcement of that to the user boundary
final def INVALID_PASSWORDS = ['', null]
// Act
INVALID_PASSWORDS.each { String invalidPassword ->
logger.info("Using password: ${invalidPassword}")
def msg = shouldFail(IllegalArgumentException) {
boolean matches = Scrypt.check(invalidPassword, HASH)
}
logger.expected(msg)
// Assert
assert msg =~ "Password cannot be empty"
}
}
@Test
void testCheckShouldNotAcceptInvalidHash() throws Exception {
// Arrange
final String PASSWORD = "thisIsABadPassword"
// Even though the spec allows for empty salts, the JCE does not, so extend enforcement of that to the user boundary
final def INVALID_HASHES = ['', null, '$s0$a0801$', '$s0$a0801$abcdefghijklmnopqrstuv$']
// Act
INVALID_HASHES.each { String invalidHash ->
logger.info("Using hash: ${invalidHash}")
def msg = shouldFail(IllegalArgumentException) {
boolean matches = Scrypt.check(PASSWORD, invalidHash)
}
logger.expected(msg)
// Assert
assert msg =~ "Hash cannot be empty|Hash is not properly formatted"
}
}
@Test
void testVerifyHashFormatShouldDetectValidHash() throws Exception {
// Arrange
final def VALID_HASHES = [
"\$s0\$40801\$AAAAAAAAAAAAAAAAAAAAAA\$gLSh7ChbHdOIMvZ74XGjV6qF65d9qvQ8n75FeGnM8YM",
"\$s0\$40801\$ABCDEFGHIJKLMNOPQRSTUQ\$hxU5g0eH6sRkBqcsiApI8jxvKRT+2QMCenV0GToiMQ8",
"\$s0\$40801\$eO+UUcKYL2gnpD51QCc+gnywQ7Eg9tZeLMlf0XXr2zc\$99aTTB39TJo69aZCONQmRdyWOgYsDi+1MI+8D0EgMNM",
"\$s0\$40801\$AAAAAAAAAAAAAAAAAAAAAA\$Gk7K9YmlsWbd8FS7e4RKVWnkg9vlsqYnlD593pJ71gg",
"\$s0\$40801\$ABCDEFGHIJKLMNOPQRSTUQ\$Ri78VZbrp2cCVmGh2a9Nbfdov8LPnFb49MYyzPCaXmE",
"\$s0\$40801\$eO+UUcKYL2gnpD51QCc+gnywQ7Eg9tZeLMlf0XXr2zc\$rZIrP2qdIY7LN4CZAMgbCzl3YhXz6WhaNyXJXqFIjaI",
"\$s0\$40801\$AAAAAAAAAAAAAAAAAAAAAA\$GxH68bGykmPDZ6gaPIGOONOT2omlZ7cd0xlcZ9UsY/0",
"\$s0\$40801\$ABCDEFGHIJKLMNOPQRSTUQ\$KLGZjWlo59sbCbtmTg5b4k0Nu+biWZRRzhPhN7K5kkI",
"\$s0\$40801\$eO+UUcKYL2gnpD51QCc+gnywQ7Eg9tZeLMlf0XXr2zc\$6Ql6Efd2ac44ERoV31CL3Q0J3LffNZKN4elyMHux99Y",
// Uncommon but technically valid
"\$s0\$F0801\$AAAAAAAAAAA\$A",
"\$s0\$40801\$ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP\$A",
"\$s0\$40801\$ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP\$ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP",
"\$s0\$40801\$ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP\$ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP",
"\$s0\$F0801\$AAAAAAAAAAA\$A",
"\$s0\$F0801\$AAAAAAAAAAA\$A",
"\$s0\$F0801\$AAAAAAAAAAA\$A",
"\$s0\$F0801\$AAAAAAAAAAA\$A",
"\$s0\$F0801\$AAAAAAAAAAA\$A",
]
// Act
VALID_HASHES.each { String validHash ->
logger.info("Using hash: ${validHash}")
boolean isValidHash = Scrypt.verifyHashFormat(validHash)
logger.info("Hash is valid: ${isValidHash}")
// Assert
assert isValidHash
}
}
@Test
void testVerifyHashFormatShouldDetectInvalidHash() throws Exception {
// Arrange
// Even though the spec allows for empty salts, the JCE does not, so extend enforcement of that to the user boundary
final def INVALID_HASHES = ['', null, '$s0$a0801$', '$s0$a0801$abcdefghijklmnopqrstuv$']
// Act
INVALID_HASHES.each { String invalidHash ->
logger.info("Using hash: ${invalidHash}")
boolean isValidHash = Scrypt.verifyHashFormat(invalidHash)
logger.info("Hash is valid: ${isValidHash}")
// Assert
assert !isValidHash
}
}
}