Browse Source

Add SCryptPasswordEncoder

Fixes gh-3702
Shazin 9 years ago
parent
commit
7d02e259df

+ 5 - 0
crypto/crypto.gradle

@@ -10,3 +10,8 @@ configure(project.tasks.withType(Test)) {
 		exclude '**/EncryptorsTests.class'
 	}
 }
+
+dependencies {
+    optional 'org.bouncycastle:bcprov-jdk15on:1.54'
+    testCompile 'org.assertj:assertj-core:3.3.0'
+}

+ 118 - 105
crypto/pom.xml

@@ -1,105 +1,118 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
-  <modelVersion>4.0.0</modelVersion>
-  <groupId>org.springframework.security</groupId>
-  <artifactId>spring-security-crypto</artifactId>
-  <version>4.0.3.CI-SNAPSHOT</version>
-  <name>spring-security-crypto</name>
-  <description>spring-security-crypto</description>
-  <url>http://spring.io/spring-security</url>
-  <organization>
-    <name>spring.io</name>
-    <url>http://spring.io/</url>
-  </organization>
-  <licenses>
-    <license>
-      <name>The Apache Software License, Version 2.0</name>
-      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
-      <distribution>repo</distribution>
-    </license>
-  </licenses>
-  <developers>
-    <developer>
-      <id>rwinch</id>
-      <name>Rob Winch</name>
-      <email>rwinch@gopivotal.com</email>
-    </developer>
-  </developers>
-  <scm>
-    <connection>scm:git:git://github.com/spring-projects/spring-security</connection>
-    <developerConnection>scm:git:git://github.com/spring-projects/spring-security</developerConnection>
-    <url>https://github.com/spring-projects/spring-security</url>
-  </scm>
-  <repositories>
-    <repository>
-      <id>spring-snapshot</id>
-      <url>https://repo.spring.io/snapshot</url>
-    </repository>
-  </repositories>
-  <dependencies>
-    <dependency>
-      <groupId>commons-logging</groupId>
-      <artifactId>commons-logging</artifactId>
-      <version>1.2</version>
-      <scope>compile</scope>
-      <optional>true</optional>
-    </dependency>
-    <dependency>
-      <groupId>ch.qos.logback</groupId>
-      <artifactId>logback-classic</artifactId>
-      <version>1.1.2</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>junit</groupId>
-      <artifactId>junit</artifactId>
-      <version>4.11</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.easytesting</groupId>
-      <artifactId>fest-assert</artifactId>
-      <version>1.4</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.mockito</groupId>
-      <artifactId>mockito-core</artifactId>
-      <version>1.10.19</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.slf4j</groupId>
-      <artifactId>jcl-over-slf4j</artifactId>
-      <version>1.7.7</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
-      <groupId>org.springframework</groupId>
-      <artifactId>spring-test</artifactId>
-      <scope>test</scope>
-    </dependency>
-  </dependencies>
-  <dependencyManagement>
-    <dependencies>
-      <dependency>
-        <groupId>org.springframework</groupId>
-        <artifactId>spring-framework-bom</artifactId>
-        <version>4.1.6.RELEASE</version>
-        <type>pom</type>
-        <scope>import</scope>
-      </dependency>
-    </dependencies>
-  </dependencyManagement>
-  <build>
-    <plugins>
-      <plugin>
-        <artifactId>maven-compiler-plugin</artifactId>
-        <configuration>
-          <source>1.7</source>
-          <target>1.7</target>
-        </configuration>
-      </plugin>
-    </plugins>
-  </build>
-</project>
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>org.springframework.security</groupId>
+  <artifactId>spring-security-crypto</artifactId>
+  <version>4.1.0.BUILD-SNAPSHOT</version>
+  <name>spring-security-crypto</name>
+  <description>spring-security-crypto</description>
+  <url>http://spring.io/spring-security</url>
+  <organization>
+    <name>spring.io</name>
+    <url>http://spring.io/</url>
+  </organization>
+  <licenses>
+    <license>
+      <name>The Apache Software License, Version 2.0</name>
+      <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+      <distribution>repo</distribution>
+    </license>
+  </licenses>
+  <developers>
+    <developer>
+      <id>rwinch</id>
+      <name>Rob Winch</name>
+      <email>rwinch@gopivotal.com</email>
+    </developer>
+  </developers>
+  <scm>
+    <connection>scm:git:git://github.com/spring-projects/spring-security</connection>
+    <developerConnection>scm:git:git://github.com/spring-projects/spring-security</developerConnection>
+    <url>https://github.com/spring-projects/spring-security</url>
+  </scm>
+  <dependencyManagement>
+    <dependencies>
+      <dependency>
+        <groupId>org.springframework</groupId>
+        <artifactId>spring-framework-bom</artifactId>
+        <version>4.2.5.RELEASE</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
+    </dependencies>
+  </dependencyManagement>
+  <dependencies>
+    <dependency>
+      <groupId>commons-logging</groupId>
+      <artifactId>commons-logging</artifactId>
+      <version>1.2</version>
+      <scope>compile</scope>
+      <optional>true</optional>
+    </dependency>
+    <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcprov-jdk15on</artifactId>
+      <version>1.54</version>
+      <scope>compile</scope>
+      <optional>true</optional>
+    </dependency>
+    <dependency>
+      <groupId>ch.qos.logback</groupId>
+      <artifactId>logback-classic</artifactId>
+      <version>1.1.2</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <version>4.12</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <version>3.3.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.easytesting</groupId>
+      <artifactId>fest-assert</artifactId>
+      <version>1.4</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <version>1.10.19</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>jcl-over-slf4j</artifactId>
+      <version>1.7.7</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.springframework</groupId>
+      <artifactId>spring-test</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+  <repositories>
+    <repository>
+      <id>spring-snapshot</id>
+      <url>https://repo.spring.io/snapshot</url>
+    </repository>
+  </repositories>
+  <build>
+    <plugins>
+      <plugin>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <source>1.7</source>
+          <target>1.7</target>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>

+ 150 - 0
crypto/src/main/java/org/springframework/security/crypto/scrypt/SCryptPasswordEncoder.java

@@ -0,0 +1,150 @@
+/*
+ * Copyright 2002-2012 the original author or authors.
+ *
+ * Licensed 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.springframework.security.crypto.scrypt;
+
+import java.util.Base64;
+import java.util.Base64.Decoder;
+import java.util.Base64.Encoder;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.security.crypto.codec.Utf8;
+import org.springframework.security.crypto.keygen.BytesKeyGenerator;
+import org.springframework.security.crypto.keygen.KeyGenerators;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.bouncycastle.crypto.generators.SCrypt;
+
+
+
+/**
+ * Implementation of PasswordEncoder that uses the SCrypt hashing function. Clients
+ * can optionally supply a cpu cost parameter, a memory cost parameter and a parallelization parameter.
+ * 
+ * @author Shazin Sadakath
+ *
+ */
+public class SCryptPasswordEncoder implements PasswordEncoder {
+    
+    private final Log logger = LogFactory.getLog(getClass());
+    
+    private final int cpuCost;
+    
+    private final int memoryCost;
+    
+    private final int parallelization;  
+    
+    private final int keyLength;
+    
+    private final BytesKeyGenerator saltGenerator;
+    
+    public SCryptPasswordEncoder() {
+        this(16384, 8, 1, 32, 64);
+    }
+    
+    /**
+     * @param cpu cost of the algorithm. must be power of 2 greater than 1
+     * @param memory cost of the algorithm
+     * @param parallelization of the algorithm
+     * @param key length for the algorithm
+     * @param salt length
+     */
+    public SCryptPasswordEncoder(int cpuCost, int memoryCost, int parallelization, int keyLength, int saltLength) {
+        if (cpuCost <= 1) {
+            throw new IllegalArgumentException("Cpu cost parameter must be > 1.");
+        }
+        if (memoryCost == 1 && cpuCost > 65536) {
+            throw new IllegalArgumentException("Cpu cost parameter must be > 1 and < 65536.");
+        }
+        if (memoryCost < 1) {
+            throw new IllegalArgumentException("Memory cost must be >= 1.");
+        }
+        int maxParallel = Integer.MAX_VALUE / (128 * memoryCost * 8);
+        if (parallelization < 1 || parallelization > maxParallel) {
+            throw new IllegalArgumentException("Parallelisation parameter p must be >= 1 and <= " + maxParallel
+                + " (based on block size r of " + memoryCost + ")");
+        }
+        if (keyLength < 1 || keyLength > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException("Key length must be >= 1 and <= "+Integer.MAX_VALUE);
+        }
+        if (saltLength < 1 || saltLength > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException("Salt length must be >= 1 and <= "+Integer.MAX_VALUE);
+        }
+        
+        this.cpuCost = cpuCost;
+        this.memoryCost = memoryCost;
+        this.parallelization = parallelization;
+        this.keyLength = keyLength;
+        this.saltGenerator = KeyGenerators.secureRandom(saltLength);
+    }
+
+	@Override
+	public String encode(CharSequence rawPassword) {	    
+        return digest(rawPassword, saltGenerator.generateKey());
+	}
+
+	@Override
+	public boolean matches(CharSequence rawPassword, String encodedPassword) {
+		if(encodedPassword == null || encodedPassword.length() < keyLength) {
+		    logger.warn("Empty encoded password");
+		    return false;		           
+		}
+		return decodeAndCheckMatches(rawPassword, encodedPassword);		
+	}    
+	
+	private boolean decodeAndCheckMatches(CharSequence rawPassword, String encodedPassword) {
+	    String[] parts = encodedPassword.split("\\$");
+
+        if (parts.length != 4) {
+            return false;
+        }
+
+        Decoder decoder = Base64.getDecoder();
+        long params = Long.parseLong(parts[1], 16);        
+        byte[] salt = decoder.decode(parts[2]);
+        byte[] derived = decoder.decode(parts[3]);
+
+        int cpuCost = (int) Math.pow(2, params >> 16 & 0xffff);
+        int memoryCost = (int) params >> 8 & 0xff;
+        int parallelization = (int) params & 0xff;
+
+        byte[] generated = SCrypt.generate(Utf8.encode(rawPassword), salt, cpuCost, memoryCost, parallelization, keyLength);
+
+        if (derived.length != generated.length) {
+            return false;
+        }
+
+        int result = 0;
+        for (int i = 0; i < derived.length; i++) {
+            result |= derived[i] ^ generated[i];
+        }
+        return result == 0;
+	}
+	
+	private String digest(CharSequence rawPassword, byte[] salt) {	    
+	    byte[] derived = SCrypt.generate(Utf8.encode(rawPassword), salt, cpuCost, memoryCost, parallelization, 32);
+
+        String params = Long.toString(((int) (Math.log(cpuCost) / Math.log(2)) << 16L) | memoryCost << 8 | parallelization, 16);
+        Encoder encoder = Base64.getEncoder();
+        
+        StringBuilder sb = new StringBuilder((salt.length + derived.length) * 2);
+        sb.append("$").append(params).append('$');
+        sb.append(encoder.encodeToString(salt)).append('$');
+        sb.append(encoder.encodeToString(derived));
+
+        return sb.toString();  
+	}
+	
+}

+ 120 - 0
crypto/src/test/java/org/springframework/security/crypto/scrypt/SCryptPasswordEncoderTests.java

@@ -0,0 +1,120 @@
+/*
+ * Copyright 2002-2012 the original author or authors.
+ *
+ * Licensed 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.springframework.security.crypto.scrypt;
+
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.Test;
+
+/**
+ * @author Shazin Sadakath
+ *
+ */
+public class SCryptPasswordEncoderTests {
+
+    @Test
+    public void matches() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        String result = encoder.encode("password");
+        assertThat(result).isNotEqualTo("password");
+        assertThat(encoder.matches("password", result)).isTrue();        
+    }
+
+    @Test
+    public void unicode() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        String result = encoder.encode("passw\u9292rd");
+        assertThat(encoder.matches("pass\u9292\u9292rd", result)).isFalse();        
+        assertThat(encoder.matches("passw\u9292rd", result)).isTrue();
+    }
+
+    @Test
+    public void notMatches() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        String result = encoder.encode("password");
+        assertThat(encoder.matches("bogus", result)).isFalse();
+    }
+    
+    @Test
+    public void customParameters() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder(512, 8, 4, 32, 16);
+        String result = encoder.encode("password");
+        assertThat(result).isNotEqualTo("password");
+        assertThat(encoder.matches("password", result)).isTrue();
+    }
+    
+    @Test
+    public void differentPasswordHashes() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        String password = "secret";
+        assertThat(encoder.encode(password)).isNotEqualTo(encoder.encode(password));
+    }
+    
+    @Test
+    public void samePasswordWithDifferentParams() {
+        SCryptPasswordEncoder oldEncoder = new SCryptPasswordEncoder(512, 8, 4, 64, 16);
+        SCryptPasswordEncoder newEncoder = new SCryptPasswordEncoder();
+
+        String password = "secret";
+        String oldEncodedPassword = oldEncoder.encode(password);
+        assertThat(newEncoder.matches(password, oldEncodedPassword)).isTrue();
+    }
+
+    @Test
+    public void doesntMatchNullEncodedValue() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        assertThat(encoder.matches("password", null)).isFalse();
+    }
+
+    @Test
+    public void doesntMatchEmptyEncodedValue() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        assertThat(encoder.matches("password", "")).isFalse();
+    }
+
+    @Test
+    public void doesntMatchBogusEncodedValue() {
+        SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
+        assertThat(encoder.matches("password", "012345678901234567890123456789")).isFalse();
+    }
+    
+    @Test(expected = IllegalArgumentException.class)
+    public void invalidCpuCostParameter() {
+        new SCryptPasswordEncoder(Integer.MIN_VALUE, 16, 2, 32, 16);     
+    }   
+    
+    @Test(expected = IllegalArgumentException.class)
+    public void invalidMemoryCostParameter() {
+        new SCryptPasswordEncoder(2, Integer.MAX_VALUE, 2, 32, 16);     
+    }
+    
+    @Test(expected = IllegalArgumentException.class)
+    public void invalidParallelizationParameter() {
+        new SCryptPasswordEncoder(2, 8, Integer.MAX_VALUE, 32, 16);     
+    }
+    
+    @Test(expected = IllegalArgumentException.class)
+    public void invalidSaltLengthParameter() {
+        new SCryptPasswordEncoder(2, 8, 1, 16, -1);     
+    }
+    
+    @Test(expected = IllegalArgumentException.class)
+    public void invalidKeyLengthParameter() {
+        new SCryptPasswordEncoder(2, 8, 1, -1, 16);     
+    }
+
+}
+