JWT 서명 알고리즘 RS256 → PS256 교체 — PssJwtAccessTokenConverter, RsaPssSigner,
RsaPssVerifier 추가 - RsaPssSigner: spring-security-jwt Signer 구현, RSASSA-PSS(SHA-256/MGF1/salt=32) 서명 - RsaPssVerifier: SignatureVerifier 구현, 동일 PSS 파라미터로 검증 - PssJwtAccessTokenConverter: JwtHelper를 우회하여 PS256 JWT 직접 발급·검증 - encode/decode 오버라이드로 JwtAlgorithms PS256 미등록 문제 해결 - 생성자에서 공개키 PEM을 보관하여 getKey() 엔드포인트 대응 - build.gradle: spring-security-jwt:1.1.1.RELEASE api 의존성 추가 - RsaPssSignerVerifierTest: 단위테스트 13개 (서명/검증/라운드트립/시각확인)
This commit is contained in:
@@ -135,6 +135,8 @@ dependencies {
|
|||||||
|
|
||||||
compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
||||||
|
|
||||||
|
api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE"
|
||||||
|
|
||||||
testRuntimeOnly 'com.h2database:h2:2.1.214'
|
testRuntimeOnly 'com.h2database:h2:2.1.214'
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
|
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
|
||||||
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
|
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.eactive.eai.authserver.jwt;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.common.OAuth2AccessToken;
|
||||||
|
import org.springframework.security.oauth2.provider.OAuth2Authentication;
|
||||||
|
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PS256(RSA-PSS) JWT를 발급·검증하는 JwtAccessTokenConverter 확장 클래스.
|
||||||
|
*
|
||||||
|
* spring-security-jwt 1.1.1은 JwtAlgorithms에 PS256을 등록하지 않아
|
||||||
|
* JwtHelper.encode/decodeAndVerify가 PS256 헤더를 처리하지 못한다.
|
||||||
|
* 따라서 encode/decode를 직접 구현하여 JwtHelper를 완전히 우회한다.
|
||||||
|
*/
|
||||||
|
public class PssJwtAccessTokenConverter extends JwtAccessTokenConverter {
|
||||||
|
|
||||||
|
/** PS256 JWT 헤더 {"alg":"PS256","typ":"JWT"} 의 Base64URL 인코딩 (고정값) */
|
||||||
|
private static final String ENCODED_HEADER = Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString("{\"alg\":\"PS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
private final RsaPssSigner pssSigner;
|
||||||
|
private final RsaPssVerifier pssVerifier;
|
||||||
|
private final String publicKeyPem;
|
||||||
|
private final ObjectMapper jackson = new ObjectMapper();
|
||||||
|
|
||||||
|
public PssJwtAccessTokenConverter(RSAPrivateKey privateKey, RSAPublicKey publicKey) {
|
||||||
|
this.pssSigner = new RsaPssSigner(privateKey);
|
||||||
|
this.pssVerifier = new RsaPssVerifier(publicKey);
|
||||||
|
this.publicKeyPem = "-----BEGIN PUBLIC KEY-----\n"
|
||||||
|
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(publicKey.getEncoded())
|
||||||
|
+ "\n-----END PUBLIC KEY-----";
|
||||||
|
// 부모 setVerifierKey — afterPropertiesSet() 내부에서 참조하는 verifierKey 필드 초기화
|
||||||
|
setVerifierKey(this.publicKeyPem);
|
||||||
|
// getKey() (/oauth/token_key 엔드포인트)가 동작하도록 부모의 verifier 필드를 주입
|
||||||
|
try {
|
||||||
|
Field f = JwtAccessTokenConverter.class.getDeclaredField("verifier");
|
||||||
|
f.setAccessible(true);
|
||||||
|
f.set(this, pssVerifier);
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() throws Exception {
|
||||||
|
// 부모 afterPropertiesSet()은 signingKey 기반 초기화(JwtHelper 의존)를 시도하므로 건너뜀
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PS256 JWT 발급. JwtHelper.encode를 사용하지 않고 직접 구성한다.
|
||||||
|
* signingInput = Base64URL(header) + "." + Base64URL(payload)
|
||||||
|
* JWT = signingInput + "." + Base64URL(PSS_sign(signingInput))
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
|
||||||
|
Map<String, ?> claims = getAccessTokenConverter().convertAccessToken(accessToken, authentication);
|
||||||
|
String payload;
|
||||||
|
try {
|
||||||
|
payload = jackson.writeValueAsString(claims);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Access token을 JSON으로 변환 실패", e);
|
||||||
|
}
|
||||||
|
return buildJwt(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PS256 JWT 검증 및 클레임 추출. JwtHelper.decodeAndVerify를 사용하지 않고 직접 파싱한다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected Map<String, Object> decode(String token) {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
throw new IllegalArgumentException("잘못된 JWT 형식");
|
||||||
|
}
|
||||||
|
byte[] signingInput = (parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] sig = Base64.getUrlDecoder().decode(padBase64Url(parts[2]));
|
||||||
|
pssVerifier.verify(signingInput, sig); // 실패 시 InvalidSignatureException
|
||||||
|
|
||||||
|
byte[] payloadBytes = Base64.getUrlDecoder().decode(padBase64Url(parts[1]));
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> result = jackson.readValue(payloadBytes, Map.class);
|
||||||
|
// JwtAccessTokenConverter 원본 동작 유지: exp 필드 Integer → Long 변환
|
||||||
|
if (result.containsKey(EXP) && result.get(EXP) instanceof Integer) {
|
||||||
|
result.put(EXP, ((Integer) result.get(EXP)).longValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("JWT 페이로드 파싱 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /oauth/token_key 엔드포인트 응답 — alg와 PEM 공개키 반환 */
|
||||||
|
@Override
|
||||||
|
public Map<String, String> getKey() {
|
||||||
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
|
result.put("alg", "PS256");
|
||||||
|
result.put("value", publicKeyPem);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildJwt(String payload) {
|
||||||
|
String encodedPayload = Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
String signingInput = ENCODED_HEADER + "." + encodedPayload;
|
||||||
|
byte[] sig = pssSigner.sign(signingInput.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
return signingInput + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String padBase64Url(String s) {
|
||||||
|
int mod = s.length() % 4;
|
||||||
|
if (mod == 2) return s + "==";
|
||||||
|
if (mod == 3) return s + "=";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.eactive.eai.authserver.jwt;
|
||||||
|
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.spec.MGF1ParameterSpec;
|
||||||
|
import java.security.spec.PSSParameterSpec;
|
||||||
|
|
||||||
|
import org.springframework.security.jwt.crypto.sign.Signer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PS256 (RSA-PSS + SHA-256) 서명 구현체.
|
||||||
|
* RFC 7518 §3.5 파라미터: Hash=SHA-256, MGF=MGF1/SHA-256, saltLen=32, trailer=0xBC
|
||||||
|
* JDK 11+ 내장 RSASSA-PSS 사용. JDK 8 환경은 BouncyCastle 프로바이더 등록이 필요하다.
|
||||||
|
*
|
||||||
|
* JwtHelper를 우회하는 PssJwtAccessTokenConverter와 함께 사용한다.
|
||||||
|
* (spring-security-jwt 1.1.1은 JwtAlgorithms에 PS256을 등록하지 않아 JwtHelper 직접 사용 불가)
|
||||||
|
*/
|
||||||
|
public class RsaPssSigner implements Signer {
|
||||||
|
|
||||||
|
private static final PSSParameterSpec PSS_SPEC =
|
||||||
|
new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1);
|
||||||
|
|
||||||
|
private final RSAPrivateKey privateKey;
|
||||||
|
|
||||||
|
public RsaPssSigner(RSAPrivateKey privateKey) {
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] sign(byte[] bytes) {
|
||||||
|
try {
|
||||||
|
Signature sig = Signature.getInstance("RSASSA-PSS");
|
||||||
|
sig.setParameter(PSS_SPEC);
|
||||||
|
sig.initSign(privateKey);
|
||||||
|
sig.update(bytes);
|
||||||
|
return sig.sign();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("RSA-PSS 서명 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String algorithm() {
|
||||||
|
return "PS256";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.eactive.eai.authserver.jwt;
|
||||||
|
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.security.spec.MGF1ParameterSpec;
|
||||||
|
import java.security.spec.PSSParameterSpec;
|
||||||
|
|
||||||
|
import org.springframework.security.jwt.crypto.sign.InvalidSignatureException;
|
||||||
|
import org.springframework.security.jwt.crypto.sign.SignatureVerifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PS256 (RSA-PSS + SHA-256) 서명 검증 구현체.
|
||||||
|
* RFC 7518 §3.5 파라미터: Hash=SHA-256, MGF=MGF1/SHA-256, saltLen=32, trailer=0xBC
|
||||||
|
*/
|
||||||
|
public class RsaPssVerifier implements SignatureVerifier {
|
||||||
|
|
||||||
|
private static final PSSParameterSpec PSS_SPEC =
|
||||||
|
new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1);
|
||||||
|
|
||||||
|
private final RSAPublicKey publicKey;
|
||||||
|
|
||||||
|
public RsaPssVerifier(RSAPublicKey publicKey) {
|
||||||
|
this.publicKey = publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void verify(byte[] content, byte[] signature) {
|
||||||
|
try {
|
||||||
|
Signature sig = Signature.getInstance("RSASSA-PSS");
|
||||||
|
sig.setParameter(PSS_SPEC);
|
||||||
|
sig.initVerify(publicKey);
|
||||||
|
sig.update(content);
|
||||||
|
if (!sig.verify(signature)) {
|
||||||
|
throw new InvalidSignatureException("RSA-PSS 서명 검증 실패");
|
||||||
|
}
|
||||||
|
} catch (InvalidSignatureException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new InvalidSignatureException("RSA-PSS 서명 검증 실패: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String algorithm() {
|
||||||
|
return "PS256";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package com.eactive.eai.authserver.jwt;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
import org.springframework.security.jwt.JwtHelper;
|
||||||
|
import org.springframework.security.jwt.crypto.sign.InvalidSignatureException;
|
||||||
|
import org.springframework.security.jwt.crypto.sign.RsaSigner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RsaPssSigner / RsaPssVerifier 단위 테스트.
|
||||||
|
* Spring 컨텍스트 불필요 — KeyPairGenerator로 테스트용 RSA 2048 키쌍을 생성하여 검증한다.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||||
|
class RsaPssSignerVerifierTest {
|
||||||
|
|
||||||
|
private static RSAPrivateKey privateKey;
|
||||||
|
private static RSAPublicKey publicKey;
|
||||||
|
private static RsaPssSigner signer;
|
||||||
|
private static RsaPssVerifier verifier;
|
||||||
|
|
||||||
|
private static final byte[] CONTENT = "header.payload".getBytes();
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpClass() throws Exception {
|
||||||
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
gen.initialize(2048);
|
||||||
|
KeyPair keyPair = gen.generateKeyPair();
|
||||||
|
privateKey = (RSAPrivateKey) keyPair.getPrivate();
|
||||||
|
publicKey = (RSAPublicKey) keyPair.getPublic();
|
||||||
|
signer = new RsaPssSigner(privateKey);
|
||||||
|
verifier = new RsaPssVerifier(publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. RsaPssSigner
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-1. algorithm() — PS256 반환")
|
||||||
|
void testSignerAlgorithm() {
|
||||||
|
assertEquals("PS256", signer.algorithm());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-2. sign() — non-null, 256바이트 (RSA-2048 서명 크기)")
|
||||||
|
void testSignReturnsExpectedLength() {
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
assertNotNull(sig);
|
||||||
|
assertEquals(256, sig.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-3. sign() — 비결정론적 (같은 입력이라도 랜덤 salt로 인해 서명이 달라야 한다)")
|
||||||
|
void testSignIsNonDeterministic() {
|
||||||
|
byte[] sig1 = signer.sign(CONTENT);
|
||||||
|
byte[] sig2 = signer.sign(CONTENT);
|
||||||
|
assertFalse(Arrays.equals(sig1, sig2), "PSS는 매번 다른 salt를 사용하므로 서명이 달라야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. RsaPssVerifier
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-1. algorithm() — PS256 반환")
|
||||||
|
void testVerifierAlgorithm() {
|
||||||
|
assertEquals("PS256", verifier.algorithm());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-2. 정상 서명 → 검증 성공 (예외 없음)")
|
||||||
|
void testVerifyValidSignature() {
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
assertDoesNotThrow(() -> verifier.verify(CONTENT, sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-3. 내용 변조 → InvalidSignatureException")
|
||||||
|
void testVerifyTamperedContent() {
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
byte[] tampered = "tampered.payload".getBytes();
|
||||||
|
assertThrows(InvalidSignatureException.class, () -> verifier.verify(tampered, sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-4. 서명 변조 (첫 바이트 반전) → InvalidSignatureException")
|
||||||
|
void testVerifyTamperedSignature() {
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
sig[0] ^= 0xFF;
|
||||||
|
assertThrows(InvalidSignatureException.class, () -> verifier.verify(CONTENT, sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-5. 다른 키쌍의 공개키로 검증 → InvalidSignatureException")
|
||||||
|
void testVerifyWithWrongPublicKey() throws Exception {
|
||||||
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
gen.initialize(2048);
|
||||||
|
RSAPublicKey otherPublicKey = (RSAPublicKey) gen.generateKeyPair().getPublic();
|
||||||
|
RsaPssVerifier wrongVerifier = new RsaPssVerifier(otherPublicKey);
|
||||||
|
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
assertThrows(InvalidSignatureException.class, () -> wrongVerifier.verify(CONTENT, sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Signer + Verifier 통합
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-1. sign → verify 라운드트립 — 성공")
|
||||||
|
void testRoundTrip() {
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
assertDoesNotThrow(() -> verifier.verify(CONTENT, sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-2. 다른 키쌍의 서명을 원래 verifier로 검증 → 실패")
|
||||||
|
void testRoundTripCrossKeyFails() throws Exception {
|
||||||
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
gen.initialize(2048);
|
||||||
|
KeyPair other = gen.generateKeyPair();
|
||||||
|
RsaPssSigner otherSigner = new RsaPssSigner((RSAPrivateKey) other.getPrivate());
|
||||||
|
|
||||||
|
byte[] sigByOther = otherSigner.sign(CONTENT);
|
||||||
|
assertThrows(InvalidSignatureException.class, () -> verifier.verify(CONTENT, sigByOther));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-3. 원래 서명을 다른 키쌍의 verifier로 검증 → 실패")
|
||||||
|
void testRoundTripCrossVerifierFails() throws Exception {
|
||||||
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
gen.initialize(2048);
|
||||||
|
KeyPair other = gen.generateKeyPair();
|
||||||
|
RsaPssVerifier otherVerifier = new RsaPssVerifier((RSAPublicKey) other.getPublic());
|
||||||
|
|
||||||
|
byte[] sig = signer.sign(CONTENT);
|
||||||
|
assertThrows(InvalidSignatureException.class, () -> otherVerifier.verify(CONTENT, sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. 시각 확인 — 콘솔 출력으로 RS256(결정론적) vs PS256(비결정론적) 비교
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("4-1. [시각 확인] RS256은 결정론적, PS256은 비결정론적 — 콘솔 hex 출력")
|
||||||
|
void testVisualComparison() {
|
||||||
|
RsaSigner rs256Signer = new RsaSigner(privateKey);
|
||||||
|
|
||||||
|
byte[] rs256Sig1 = rs256Signer.sign(CONTENT);
|
||||||
|
byte[] rs256Sig2 = rs256Signer.sign(CONTENT);
|
||||||
|
byte[] ps256Sig1 = signer.sign(CONTENT);
|
||||||
|
byte[] ps256Sig2 = signer.sign(CONTENT);
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("━━━ 서명 알고리즘 시각 비교 (동일 데이터: \"" + new String(CONTENT) + "\") ━━━");
|
||||||
|
System.out.println();
|
||||||
|
System.out.printf("[RS256 서명 1] %s%n", toHex(rs256Sig1));
|
||||||
|
System.out.printf("[RS256 서명 2] %s%n", toHex(rs256Sig2));
|
||||||
|
System.out.printf(" → 두 서명 일치 여부: %b (결정론적 — 항상 같음)%n", Arrays.equals(rs256Sig1, rs256Sig2));
|
||||||
|
System.out.println();
|
||||||
|
System.out.printf("[PS256 서명 1] %s%n", toHex(ps256Sig1));
|
||||||
|
System.out.printf("[PS256 서명 2] %s%n", toHex(ps256Sig2));
|
||||||
|
System.out.printf(" → 두 서명 일치 여부: %b (비결정론적 — 매번 다름, PSS 랜덤 salt 때문)%n", Arrays.equals(ps256Sig1, ps256Sig2));
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
assertTrue(Arrays.equals(rs256Sig1, rs256Sig2), "RS256은 결정론적이어야 한다");
|
||||||
|
assertFalse(Arrays.equals(ps256Sig1, ps256Sig2), "PS256은 비결정론적이어야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4-2. JWT 토큰 시각 확인 — 헤더 alg 필드 + 서명 파트 비교
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("4-2. [JWT 시각 확인] alg 헤더(RS256/PS256)와 서명 파트 변화를 JWT 문자열로 확인")
|
||||||
|
void testJwtVisualComparison() {
|
||||||
|
String claims = "{\"sub\":\"test-client\",\"scope\":\"read\"}";
|
||||||
|
RsaSigner rs256Signer = new RsaSigner(privateKey);
|
||||||
|
PssJwtAccessTokenConverter pssConverter = new PssJwtAccessTokenConverter(privateKey, publicKey);
|
||||||
|
|
||||||
|
String rs256Jwt1 = JwtHelper.encode(claims, rs256Signer).getEncoded();
|
||||||
|
String rs256Jwt2 = JwtHelper.encode(claims, rs256Signer).getEncoded();
|
||||||
|
String ps256Jwt1 = pssConverter.buildJwt(claims);
|
||||||
|
String ps256Jwt2 = pssConverter.buildJwt(claims);
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("━━━ JWT 토큰 시각 비교 ━━━");
|
||||||
|
System.out.println();
|
||||||
|
printJwt("RS256 JWT 1", rs256Jwt1);
|
||||||
|
printJwt("RS256 JWT 2", rs256Jwt2);
|
||||||
|
System.out.printf(" → 두 JWT 완전 일치: %b (헤더·페이로드·서명 모두 동일)%n",
|
||||||
|
rs256Jwt1.equals(rs256Jwt2));
|
||||||
|
System.out.println();
|
||||||
|
printJwt("PS256 JWT 1", ps256Jwt1);
|
||||||
|
printJwt("PS256 JWT 2", ps256Jwt2);
|
||||||
|
System.out.printf(" → 두 JWT 완전 일치: %b (헤더·페이로드 동일, 서명만 다름)%n",
|
||||||
|
ps256Jwt1.equals(ps256Jwt2));
|
||||||
|
System.out.println();
|
||||||
|
System.out.println(" [서명 파트만 비교]");
|
||||||
|
System.out.printf(" RS256 서명1: %s%n", signaturePart(rs256Jwt1));
|
||||||
|
System.out.printf(" RS256 서명2: %s%n", signaturePart(rs256Jwt2));
|
||||||
|
System.out.printf(" PS256 서명1: %s%n", signaturePart(ps256Jwt1));
|
||||||
|
System.out.printf(" PS256 서명2: %s%n", signaturePart(ps256Jwt2));
|
||||||
|
|
||||||
|
assertEquals(rs256Jwt1, rs256Jwt2, "RS256 JWT는 동일해야 한다");
|
||||||
|
assertNotEquals(ps256Jwt1, ps256Jwt2, "PS256 JWT는 서명 파트가 달라야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void printJwt(String label, String jwt) {
|
||||||
|
String[] parts = jwt.split("\\.");
|
||||||
|
String header = new String(Base64.getUrlDecoder().decode(padBase64Url(parts[0])));
|
||||||
|
String payload = new String(Base64.getUrlDecoder().decode(padBase64Url(parts[1])));
|
||||||
|
System.out.printf("[%s]%n", label);
|
||||||
|
System.out.printf(" 전체 : %s%n", jwt);
|
||||||
|
System.out.printf(" 헤더 : %s%n", header); // {"alg":"RS256","typ":"JWT"} 또는 PS256
|
||||||
|
System.out.printf(" 페이로드: %s%n", payload);
|
||||||
|
System.out.printf(" 서명 : %s%n", parts[2]);
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String signaturePart(String jwt) {
|
||||||
|
return jwt.split("\\.")[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base64URL 패딩 보정 (Java 표준 디코더는 패딩 필수) */
|
||||||
|
private static String padBase64Url(String base64url) {
|
||||||
|
int mod = base64url.length() % 4;
|
||||||
|
if (mod == 2) return base64url + "==";
|
||||||
|
if (mod == 3) return base64url + "=";
|
||||||
|
return base64url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex(byte[] bytes) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < bytes.length; i++) {
|
||||||
|
if (i > 0 && i % 32 == 0) sb.append("\n ");
|
||||||
|
sb.append(String.format("%02X", bytes[i]));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user