인디노트

공인인증서로 전자서명하기 본문

인증기술

공인인증서로 전자서명하기

인디개발자 2018. 6. 18. 17:30

인터넷 뱅킹을 하시는 분이라면 대부분 공인인증서를 가지고 있다. 이 공인인증서를 가지고 전자서명을 해보도록하자(전혀 쓸데없는 일이긴 하다 ^^;)
필자의 경우 yessign에서 발급한 은행용 공인인증서를 가지고 있는데 그 경로는 C:\NPKI\yessign\USER\아래폴더... 에 위치해 있다.
그 디렉토리에 보면 CaPubs, signCert.der, signPri.key 세 파일이 존재한다.
CaPubs은 무슨 파일인지 잘 모르겠다. signCert.der는 공인 인증서 파일이고, signPri.key는 개인키 파일이다.
(der은 인증서 저장시 바이너르 형태로 저장하기 위한 포맷이고, pem은 문자열로 표현가능한 데이터로 인코딩(BASE64같은..)한 포맷이다.)
한국정보보호진흥원(http://www.rootca.or.kr/kcac.html)의 기술규격을 참조해보면, 현재 사용하는 공인인증서는 RFC3280을 준수하여, 전자서명인증체계에서 사용하는 정수2를 갖는 X.509 v3을 사용하고 있다고 한다.

1. 공개키 가져오기.
 - 자바에서 X.590를 지원해주니 간단히 사용해보자.

package test.security;
 
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
 
public class CertificateTest1 {
 
	public static void main(String[] args) throws Exception {
		X509Certificate cert = null;
		FileInputStream fis = null;
		try {
			fis = new FileInputStream(new File("C:/signCert.der"));
			CertificateFactory certificateFactory = CertificateFactory.getInstance("X509");
			cert = (X509Certificate) certificateFactory.generateCertificate(fis);
		} finally {
			if (fis != null) try {fis.close();} catch(IOException ie) {}
		}
		System.out.println(cert);
		System.out.println("-----------------");
		System.out.println(cert.getPublicKey());
	}
}


실행해보면 아래처럼 인증서에 대한 정보를 볼 수 있을것이다.(보안 관계상 많은 부분을 생략하겠다.)

[
[
  Version: V3
  Subject: CN=누굴까(RangWoo)0000000000000000, OU=XXX, OU=personalXXX, O=yessign, C=kr
  Signature Algorithm: SHA1withRSA, OID = 1.2.840.113549.1.1.5

  Key:  Sun RSA public key, 1024 bits
... 생략 ...
[7]: ObjectId: 1.3.6.1.5.5.7.1.1 Criticality=false
AuthorityInfoAccess [
  [accessMethod: 1.3.6.1.5.5.7.48.1
   accessLocation: URIName: http://ocsp.yessign.org:4612]
]
... 생략 ...
Sun RSA public key, 1024 bits
  modulus: 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
  public exponent: 00000

당연히, V3 버젼을 사용하고 서명 알고리즘은 SHA1withRSA을 사용한다. SHA1withRSA 옆에 보면 OID란 놈이 있다.
OID란 Object IDentifier의 약어로서 객체식별체계정도로 이해하면 되겠다. 즉, OID의 값이 1.2.840.113549.1.1.5이면 SHA1withRSA란 의미이다.
http://www.oid-info.com/ 사이트에 가서 1.2.840.113549.1.1.5 값을 입력하면 아래와 같은 값을 얻을 수 있다.


그리고 중간쯤에 ocsp(Online Certificate Status Protocol)라고 실시간으로 인증서 유효성 검증을 할수 있는 정보도 나온다.
좀더 내려가보면 공개키부분이 나오는데, 이놈이 우리가 사용할 부분이다. cert.getPublicKey() 메소드를 이용하면 직접 공개키를 가져올 수 있다.

2. 개인키 가져오기
 - 공개키는 거의 날로 먹었는데, 개인키란 놈은 만만하지가 않다. 
 - 기본적으로(?)는 PKCS#8를 이용해서 개인키를 저장하는데, 국내 공인인증서에 사용하는 개인키 파일는 암호화(?)해서 저장한다. 
PKCS#5(Password-Based Cryptography Standard)의 PBKDF1(Password-Based Key Derivation Function), PBES1(Password-Based Encryption Scheme)를 이용한다는 것이다.
여기까지는 별 문제가 없는데, 데이터 암호화를 할때 국내에서만 사용하는 SEED란 블럭암호화 알고리즘를 사용한다는것이다.
즉, 기본적으로 제공이 안되므로 직접 구현을 해야한다. 
뭔소리인지 이해가 안가면 한국정보보호진흥원(http://www.rootca.or.kr/)의 암호 알고리즘 규격(KCAC.TS.ENC)를 한번 읽어보자. (사실 읽어봐도 이해가 안가지만... ^^;)
  간단히 설명을 하자면, PBES(Password-Based Encryption Scheme) 즉 패스워드 기반의 키 암호화 기법을 사용하겠다는 것이다. 암호화 할때 필요한게 비밀키이다. 이 키는 해당 알고리즘에 맞는 바이트 배열로 보통 사용을 하는데, 이것을 사람이 쉽게 인식할 수 있는 패스워드로 사용하겠다는것이다.
 뭐 필자처럼 무식하게 "hello123".getBytes(); 를 사용해서 키로 사용할 수 있지만, 모양새가 안좋아보인다는것이다. 그래서 "hello123" 문자열을 가공해서 멋진(?) 키로 만들어 사용한다는 것이다.
이 가공하는 함수가 PBKDF(Password-Based Key Derivation Function)이다. 그리고 이 함수를 이용해서 비밀키를 생성해서 암호화/복화하는 하는 구조를 PBES라고 한다.
자바에서 기본적으로 "PBEWithMD5AndDES", "PBEWithSHA1AndDESede" 등의 알고리즘을 제공해준다.
Security.getProviders(); 메소드를 이용해서, Provider 정보를 출력해보면 지원하는 알고리즘을 알 수 있다.

package test.security;
 
import java.security.Provider;
import java.security.Security;
 
public class ProviderInfo {
 
	public static void main(String[] args) {
		Provider[] providers = Security.getProviders();
		for (int i = 0; i < providers.length; i++) {
			String name = providers[i].getName();
			String info = providers[i].getInfo();
			double version = providers[i].getVersion();
			System.out.println("--------------------------------------------------");
			System.out.println("name: " + name);
			System.out.println("info: " + info);
			System.out.println("version: " + version);
 
			for (Object key : providers[i].keySet()) {
				System.out.println(key + "\t"+ providers[i].getProperty((String)key));
			}
		}
	}
}


그런데 불행히도 "PBEWithSHA1AndSeed"같은 알고리즘은 없는거 같다. 어떻게 해야할까? 당연히 삽~을 들어야한다.(아~~ 또 무덤을 파는구나 ㅠㅠ)
일단 파일의 구조를 파악해서 필요한 정보를 읽어와야한다.(ASN. 1으로 인코딩되어있다.)
다행히도 PKCS#8로 정의하고 있는 구조를 읽을 수 있는 EncryptedPrivateKeyInfo 클래스가 존재해서 한결 쉽게 작업을 할 수 있다
EncryptedPrivateKeyInfo 클래스를 사용해서 정보를 읽어오자. 사용하는 알고리즘을 출력해 보자.

		// 1. 개인키 파일 읽어오기
		byte[] encodedKey = null;
		FileInputStream fis = null;
		ByteArrayOutputStream bos = null;
		try {
			fis = new FileInputStream(new File("C:/signPri.key"));
			bos = new ByteArrayOutputStream();
			byte[] buffer = new byte[1024];
			int read = -1;
			while ((read = fis.read(buffer)) != -1) {
				bos.write(buffer, 0, read);
			}
			encodedKey = bos.toByteArray();
		} finally {
			if (bos != null) try {bos.close();} catch(IOException ie) {}
			if (fis != null) try {fis.close();} catch(IOException ie) {}
		}
 
		System.out.println("EncodedKey : " + ByteUtils.toHexString(encodedKey));
		
		// 2. 개인카 파일 분석하기
		EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = new EncryptedPrivateKeyInfo(encodedKey);
		System.out.println(encryptedPrivateKeyInfo);
		System.out.println(encryptedPrivateKeyInfo.getAlgName());


필자의 경우  "1.2.410.200004.1.15"란 값을 얻을 수 있었다. 나머지 파라메터 정보는 불행히도 제공을 안해줘서 직접 처리해야한다.
"1.2.410.200004.1.15" 어디서 많이 본 형식이다. 그렇다. OID이다. 사이트(http://www.oid-info.com/)가서 조회를 해보자.
"Key Generation with SHA1 and Encryption with SEED CBC mode" 란다.


한국정보보호진흥원(http://www.rootca.or.kr/)의 암호 알고리즘 규격(KCAC.TS.ENC)에서도 해당 OID에 대한 정보를 알 수 있다. 


  즉, 두 번째 방법이라는 것인데, DK의 값을 이용해서 해쉬값을 만든다음 그 값을 IV(초기화 벡터)로 사용하라는 것이다.
 여기서 DK란 PBKDF를 사용해서 만든 추출키를 의미한다. 그렇다면 먼저 추출키를 만들어보자.


  위의 설명대로 해당 함수를 구현해보자. 
 salt와 iteration count가 필요하다.
 salt는 공인인증서를 발급할때마다 랜덤하게 생성되는것으로, 블특정다수의 사전(Dictionary) 공격을 방지하는 역할을 한다.(21-28바이트 사이의 8바이트를 사용함)
 iteration count는 비밀키 생성을 위해 해쉬함수를 몇번 반복할 것인가를 나타낸다. (31-32바이트 사이의 2바이트를 사용함)

		byte[] salt = new byte[8];
		System.arraycopy(encodedKey, 20, salt, 0, 8);
		System.out.println("salt : " + ByteUtils.toHexString(salt));
		byte[] cBytes = new byte[4];
		System.arraycopy(encodedKey, 30, cBytes, 2, 2);
		int iterationCount = ByteUtils.toInt(cBytes);
		System.out.println("iterationCount : " + ByteUtils.toHexString(cBytes));
		System.out.println("iterationCount : " + iterationCount);


그럼 PBKDF1을 구현해보자. RFC2898(http://www.ietf.org/rfc/rfc2898.txt)을 보면 아래처럼 설명이 나와있다.

5.1 PBKDF1
 
   PBKDF1 applies a hash function, which shall be MD2 [6], MD5 [19] or
   SHA-1 [18], to derive keys. The length of the derived key is bounded
   by the length of the hash function output, which is 16 octets for MD2
   and MD5 and 20 octets for SHA-1. PBKDF1 is compatible with the key
   derivation process in PKCS #5 v1.5.
 
   PBKDF1 is recommended only for compatibility with existing
   applications since the keys it produces may not be large enough for
   some applications.
 
   PBKDF1 (P, S, c, dkLen)
 
   Options:        Hash       underlying hash function
 
   Input:          P          password, an octet string
                   S          salt, an eight-octet string
                   c          iteration count, a positive integer
                   dkLen      intended length in octets of derived key,
                              a positive integer, at most 16 for MD2 or
                              MD5 and 20 for SHA-1
 
   Output:         DK         derived key, a dkLen-octet string
 
   Steps:
 
      1. If dkLen > 16 for MD2 and MD5, or dkLen > 20 for SHA-1, output
         "derived key too long" and stop.
 
      2. Apply the underlying hash function Hash for c iterations to the
         concatenation of the password P and the salt S, then extract
         the first dkLen octets to produce a derived key DK:
 
                   T_1 = Hash (P || S) ,
                   T_2 = Hash (T_1) ,
                   ...
                   T_c = Hash (T_{c-1}) ,
                   DK = Tc<0..dkLen-1>
 
      3. Output the derived key DK.

  설명대로 구현해주자. 피곤한 관계상 SHA1을 사용해서 20바이트의 추출키만을 반환하도록 만들었다.

	public static byte[] pbkdf1(String password, byte[] salt, int iterationCount) throws NoSuchAlgorithmException {
		byte[] dk = new byte[20];
		MessageDigest md = MessageDigest.getInstance("SHA1");
		md.update(password.getBytes());
		md.update(salt);
		dk = md.digest();
		for (int i = 1; i < iterationCount; i++) {
			dk = md.digest(dk);
		}
		return dk;
	}
}


해당 함수를 사용해서 추출키(DK) 초기화 벡터(IV)를 만들어 보자.

		String password = "password";
 
		// 추출키(DK) 생성
		byte[] dk = pbkdf1(password, salt, iterationCount);
		System.out.println("dk : " + ByteUtils.toHexString(dk));
		
		// 생성된 추출키(DK)에서 처음 16바이트를 암호화 키(K)로 정의한다.
		byte[] keyData = new byte[16];
		System.arraycopy(dk, 0, keyData, 0, 16);
		
	    // 추출키(DK)에서 암호화 키(K)를 제외한 나머지 4바이트를 SHA-1
	    // 으로 해쉬하여 20바이트의 값(DIV)을 생성하고,  그 중 처음 16바이트를 초기 
	    // 벡터(IV)로 정의한다.
		byte[] div = new byte[20];
		byte[] tmp4Bytes = new byte[4];
		System.arraycopy(dk, 16, tmp4Bytes, 0, 4);
		div = SHA1Utils.getHash(tmp4Bytes);
		System.out.println("div : " + ByteUtils.toHexString(div));
		byte[] iv = new byte[16];
		System.arraycopy(div, 0, iv, 0, 16);
		System.out.println("iv : " + ByteUtils.toHexString(iv));


당연히 password 변수에는 공인인증서 암호를 입력해야한다. 안그러면 에러가 난다.
이제 고지가 눈앞에 보인다. 남은것은 SEED를 이용해서 복화만 하면 되는것이다. SEED 구현 + CBC 운용모드 구현을 직접하려면 정신적인 데미지가 커질 수 있으므로, 만들어놓은것을 가져다 쓰겠다.
Bouncy Castle Crypto APIs(http://www.bouncycastle.org/)를 감사하는 마음으로 가져다 쓰자.
%JAVA_HOME%/jre/lib/ext에 해당 jar파일을 복사한 다음, %JAVA_HOME%/jre/lib/security/java.security 파일에
security.provider.7=org.bouncycastle.jce.provider.BouncyCastleProvider
을 추가해서 사용할 수 있지만, 귀찮은 관계로 그냥(?) 사용하겠다.

		// 3. SEED로 복호화하기
		BouncyCastleProvider provider = new BouncyCastleProvider();
		Cipher cipher = Cipher.getInstance("SEED/CBC/PKCS5Padding", provider);
		Key key = new SecretKeySpec(keyData, "SEED");
		cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
		byte[] output = cipher.doFinal(encryptedPrivateKeyInfo.getEncryptedData());


이젠 해당 데이터로 개인키를 생성만 해주면 된다.

		PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(output);
		KeyFactory keyFactory = KeyFactory.getInstance("RSA");
		RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey)keyFactory.generatePrivate(keySpec);
		System.out.println(privateKey);

 패스워드를 일치여부는 PBES에서 정의한 패딩이 존재하는지 여부로 판단한다. 만약 잘못된 패스워드라면 
Exception in thread "main" javax.crypto.BadPaddingException: pad block corrupted
같은 에러가 발생할것이다.


그럼 마지막으로 공인인증서의 공개키와 개인키를 가지고 어제 해본 전자서명을 한번 해보자. 

package test.security;
 
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.Key;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.PKCS8EncodedKeySpec;
 
import javax.crypto.Cipher;
import javax.crypto.EncryptedPrivateKeyInfo;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
 
import kr.kangwoo.util.ByteUtils;
 
import org.bouncycastle.jce.provider.BouncyCastleProvider;
 
import com.jarusoft.util.security.SHA1Utils;
 
public class CertificateTest {
	
	public static void main(String[] args) throws Exception {
		String msg = "하늘에는 달이 없고, 땅에는 바람이 없습니다.\n사람들은 소리가 없고, 나는 마음이 없습니다.\n\n우주는 죽음인가요.\n인생은 잠인가요.";
		PublicKey publicKey = getPublicKey("C:/signCert.der");
		PrivateKey privateKey = getPrivateKey("C:/signPri.key");
 
		// 전자서명하기
		Signature signatureA = Signature.getInstance("SHA1withRSA");
		signatureA.initSign(privateKey);
		signatureA.update(msg.getBytes());
		byte[] sign = signatureA.sign();
		System.out.println("signature : " + ByteUtils.toHexString(sign));
		
		// 전사서명 검증하기
		String msgB = msg;
		Signature signatureB = Signature.getInstance("SHA1withRSA");
		signatureB.initVerify(publicKey);
		signatureB.update(msgB.getBytes());
		boolean verifty = signatureB.verify(sign);
		System.out.println("검증 결과 : " + verifty);
	}
	
	public static PublicKey getPublicKey(String file) throws Exception {
		X509Certificate cert = null;
		FileInputStream fis = null;
		try {
			fis = new FileInputStream(new File(file));
			CertificateFactory certificateFactory = CertificateFactory.getInstance("X509");
			cert = (X509Certificate) certificateFactory.generateCertificate(fis);
		} finally {
			if (fis != null) try {fis.close();} catch(IOException ie) {}
		}
		System.out.println(cert.getPublicKey());
		return cert.getPublicKey();
	}
 
	public static PrivateKey getPrivateKey(String file) throws Exception {
		// 1. 개인키 파일 읽어오기
		byte[] encodedKey = null;
		FileInputStream fis = null;
		ByteArrayOutputStream bos = null;
		try {
			fis = new FileInputStream(new File(file));
			bos = new ByteArrayOutputStream();
			byte[] buffer = new byte[1024];
			int read = -1;
			while ((read = fis.read(buffer)) != -1) {
				bos.write(buffer, 0, read);
			}
			encodedKey = bos.toByteArray();
		} finally {
			if (bos != null) try {bos.close();} catch(IOException ie) {}
			if (fis != null) try {fis.close();} catch(IOException ie) {}
		}
 
		System.out.println("EncodedKey : " + ByteUtils.toHexString(encodedKey));
		
		// 2. 개인카 파일 분석하기
		EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = new EncryptedPrivateKeyInfo(encodedKey);
		System.out.println(encryptedPrivateKeyInfo);
		System.out.println(encryptedPrivateKeyInfo.getAlgName());
		
		byte[] salt = new byte[8];
		System.arraycopy(encodedKey, 20, salt, 0, 8);
		System.out.println("salt : " + ByteUtils.toHexString(salt));
		byte[] cBytes = new byte[4];
		System.arraycopy(encodedKey, 30, cBytes, 2, 2);
		int iterationCount = ByteUtils.toInt(cBytes);
		System.out.println("iterationCount : " + ByteUtils.toHexString(cBytes));
		System.out.println("iterationCount : " + iterationCount);
 
		
		String password = "password";
 
		// 추출키(DK) 생성
		byte[] dk = pbkdf1(password, salt, iterationCount);
		System.out.println("dk : " + ByteUtils.toHexString(dk));
		
		// 생성된 추출키(DK)에서 처음 16바이트를 암호화 키(K)로 정의한다.
		byte[] keyData = new byte[16];
		System.arraycopy(dk, 0, keyData, 0, 16);
		
	    // 추출키(DK)에서 암호화 키(K)를 제외한 나머지 4바이트를 SHA-1
	    // 으로 해쉬하여 20바이트의 값(DIV)을 생성하고,  그 중 처음 16바이트를 초기 
	    // 벡터(IV)로 정의한다.
		byte[] div = new byte[20];
		byte[] tmp4Bytes = new byte[4];
		System.arraycopy(dk, 16, tmp4Bytes, 0, 4);
		div = SHA1Utils.getHash(tmp4Bytes);
		System.out.println("div : " + ByteUtils.toHexString(div));
		byte[] iv = new byte[16];
		System.arraycopy(div, 0, iv, 0, 16);
		System.out.println("iv : " + ByteUtils.toHexString(iv));
 
		// 3. SEED로 복호화하기
		BouncyCastleProvider provider = new BouncyCastleProvider();
		Cipher cipher = Cipher.getInstance("SEED/CBC/PKCS5Padding", provider);
		Key key = new SecretKeySpec(keyData, "SEED");
		cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
		byte[] output = cipher.doFinal(encryptedPrivateKeyInfo.getEncryptedData());
 
		PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(output);
		KeyFactory keyFactory = KeyFactory.getInstance("RSA");
		RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey)keyFactory.generatePrivate(keySpec);
		System.out.println(privateKey);
		return privateKey;
		
	}
 
	public static byte[] pbkdf1(String password, byte[] salt, int iterationCount) throws NoSuchAlgorithmException {
		byte[] dk = new byte[20]; // 생성이 의미가 없지만 한눈에 알아보라고 20바이트로 초기화
		MessageDigest md = MessageDigest.getInstance("SHA1");
		md.update(password.getBytes());
		md.update(salt);
		dk = md.digest();
		for (int i = 1; i < iterationCount; i++) {
			dk = md.digest(dk);
		}
		return dk;
	}
}




출처: http://fly32.net/447 [FLY32]

반응형

'인증기술' 카테고리의 다른 글

RSA PKCS의 관련 자료  (0) 2018.06.18
ASN.1  (0) 2018.06.18
공인인증서에서 개인키 획득하기 (using C#)  (0) 2018.06.18
Cipher Suites and Enforced Strong Security  (0) 2018.05.07
Node.js 에서 RSA 로 암복호화 하기  (0) 2018.05.05
Comments