2022年3月1日 星期二

使用 RS256 簽名演算法對 JWT 做簽章 - Java

JWT (JSON Web Token) 可以用於儲存不敏感的訊息來讓 client 和 server 端做溝通,

其支援不同的簽名加密演算法,例如對稱加密的 HS256 或 非對稱加密的 RS256 等,
在這篇文章裡,我記錄了一下使用了 RS256 的 JWT 驗證過程。

RS256 結合了 RSASSA-PKCS1-v1_5 和 SHA-256,
RSASSA-PKCS1-v1_5 是一種非對稱加密演算法、
而 SHA-256 是一種雜湊演算法,參考 Digital Signature with RSASSA-PKCS1-v1_5

RS256 跟 HS256 不同的地方是,RS256使用了非對稱加密的RSA演算法,所以擁有一對
Public Key (公鑰) 和 Private Key (私錀),而 HS256 只有一把鑰匙。
在安全上,HS256 需要發佈方(例如提供 API 的 server 端)和驗證方(例如呼叫 API 的 client 端)都要擁有同一把錀匙,如果這把錀匙流露出去讓不法的第三方得知,
第三方將可使用這把錀匙偽造出假訊息的 JWT token 讓 server, client 端無法查覺。

跟只使用了一個密碼的 HS256 不同,RS256 使用了一對公錀與密錀,
提供 API 的 server 方可以以不公開的密錀對 JWT 做加密雜溱演算,
client 端及 server 端都可以用 server 公開出來的公錀來驗證 JWT 的合法性,能確認 JWT 確實為
server 端所發出且沒有被遭到竄改

Note:
 https://jwt.io/ 是一個提供線上 JWT token 產生、驗證範例的網站,想要做測試時還蠻好用的,其也提供了不同的簽名演算法可供選擇。

在這篇文中,我示範了:

  1. 如何產生 RSA 的公錀及私錀。
  2. 如何使用 RSA 的私錀進行 RS256 JWT 的簽發。
  3. 如何使用 RSA 的公錀進行 RS256 JWT 的驗證。
下面直接上程式碼:

首先是有用到的 library dependency, 
pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>test</groupId>
  <artifactId>jwtTest</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  
  <dependencies>
  	<!-- https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api -->
	<dependency>
	    <groupId>javax.xml.bind</groupId>
	    <artifactId>jaxb-api</artifactId>
	    <version>2.3.0</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
	<dependency>
	    <groupId>io.jsonwebtoken</groupId>
	    <artifactId>jjwt</artifactId>
	    <version>0.9.1</version>
	</dependency>
  </dependencies>
  
</project>

JwtTest.java:

package main;

import java.io.UnsupportedEncodingException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.text.SimpleDateFormat;
import java.util.Base64;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.DefaultClaims;

public class JwtTest {

	public static void main(String[] args) throws Exception {
		KeyPair keyPair = generateRsaKeyPair();
        
		String publicKeyStr = "-----BEGIN PUBLIC KEY-----" + new String(Base64.getEncoder().encode(keyPair.getPublic().getEncoded()), "UTF-8") + "-----END PUBLIC KEY-----";
		String privateKeyStr = "-----BEGIN PRIVATE KEY-----" + new String(Base64.getEncoder().encode(keyPair.getPrivate().getEncoded()), "UTF-8") + "-----END PRIVATE KEY-----";
		
		System.out.println("Public Key: " + publicKeyStr);
		System.out.println("Private Key: " + privateKeyStr);
        
        String someProperyValue = "someName";
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");				
		Claims claims = new DefaultClaims();
        claims.setIssuer("issuer")
                .setSubject("test")
                .setExpiration(sdf.parse("2200-01-01"))
                .put("name", someProperyValue);
        
        String generatedRsaJwt = generateRsaJwt(claims, privateKeyStr);
		Claims parsedClaims = parseRsaJwt(generatedRsaJwt, publicKeyStr);
		System.out.println("JWT Token: " + generatedRsaJwt);
		//verify content
		System.out.println(someProperyValue.equals(parsedClaims.get("name", String.class))); //true
	}
	
	static String generateRsaJwt(Claims claims, String privateKeyStr) throws InvalidKeySpecException, UnsupportedEncodingException, NoSuchAlgorithmException {
		String generatedRsaJwt = null;
		
		privateKeyStr = privateKeyStr.replace("-----BEGIN PRIVATE KEY-----", "");
        privateKeyStr = privateKeyStr.replace("-----END PRIVATE KEY-----", "");
        privateKeyStr = privateKeyStr.replaceAll("\r\n", "");
        privateKeyStr = privateKeyStr.replaceAll("\\s+", "");
        
        PKCS8EncodedKeySpec keySpec_private = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyStr.getBytes("UTF-8")));
        KeyFactory keyFactory_private = KeyFactory.getInstance("RSA");
        PrivateKey privateKey = keyFactory_private.generatePrivate(keySpec_private);
        
		generatedRsaJwt = Jwts.builder()
        .setHeaderParam("typ", "JWT")
        .setClaims(claims)
        .signWith(SignatureAlgorithm.RS256, privateKey)               
        .compact();
		
		return generatedRsaJwt;
	}
	
	static Claims parseRsaJwt(String jwtToParse, String publicKeyStr) throws NoSuchAlgorithmException, InvalidKeySpecException, UnsupportedEncodingException {
		Claims parsedRsaJwtClaims = null;
		
		publicKeyStr = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "");
		publicKeyStr = publicKeyStr.replace("-----END PUBLIC KEY-----", "");
		publicKeyStr = publicKeyStr.replaceAll("\r\n", "");
		publicKeyStr = publicKeyStr.replaceAll("\\s+", "");
		
		X509EncodedKeySpec keySpec_public = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyStr.getBytes("UTF-8")));
        KeyFactory keyFactory_public = KeyFactory.getInstance("RSA");
        PublicKey publicKey_public = keyFactory_public.generatePublic(keySpec_public);
        
        Jws<Claims> jws = Jwts.parser()
        					  .setSigningKey(publicKey_public)
        					  .parseClaimsJws(jwtToParse);
        
        parsedRsaJwtClaims = jws.getBody();
		
		return parsedRsaJwtClaims;
	}
	
	static KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException, UnsupportedEncodingException {
		KeyPair keyPair = null;
		
		SecureRandom secureRandom = new SecureRandom();
		secureRandom.setSeed("test".getBytes("UTF-8"));
		
		KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");	
		keyPairGenerator.initialize(2048, secureRandom); // secureRandom is not necessary,
														 // you can also use keyPairGenerator.initialize(2048); for random seed
														 // note: if use same seed for secureRandom, will get same public key and private key.
		keyPair = keyPairGenerator.generateKeyPair();
		
		return keyPair;
	}

}

說明:

  1. generateRsaKeyPair() 會產生一對 RSA Public Key 及 Private Key,SecureRandom 並非必須,只是做個示範,要注意的是 SecureRandom 如果給予一樣的種子 (Seed) ,可能會造成每次產生出一樣的公錀及密錀 (但也有可能不會,看SecureRandom、KeyGenerator 等底層演算法而定)。
  2. generateRsaJwt() 使用私錀為 JWT 做簽章,parseRsaJwt() 使用公錀對 JWT 做驗證,如果有驗證問題的話,例如有人用了另一個錯吳的私錀 (真正的私錀不可流露出去,理論上其他第三方應無法知道真正的私錀) 做假訊息的簽章,在用公錀驗證時就會出現驗證錯的訊息:
    Exception in thread "main" io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
    

參考資料:

  1. Generating a JWT using an existing private key and RS256 algorithm
  2. 常見簽名算法之SHA256withRSA
  3. 在 Java 使用加密演算法(一):產生與儲存 RSA 的金鑰(2017-07-19 調整)
  4. Create jwt in java using Public key rsa
  5. Digital Signature with RSASSA-PKCS1-v1_5