BYOK(키 주입) Java 예제

Prev Next

VPC 환경에서 이용 가능합니다.

이 문서는 보유하고 계신 RSA 공개키, RSA/EC 개인키, AES-256 대칭키를 NCP Hardware Security Module(HSM) 파티션에 주입(BYOK)하고, 주입한 키로 암호화·복호화와 서명·검증을 수행하는 절차를 Java(LunaProvider) 예제로 안내합니다. 주입 원리와 지원 범위는 BYOK(키 주입) 개요에서 먼저 확인해 주십시오.

:::(info) (참고)
이 문서는 위에서부터 순서대로 실행하면 전 과정이 완료되도록 구성했습니다. HsmByok.java와 KwpWrap.java를 한 번 컴파일해 두고, 명령을 바꿔가며 실행하는 방식입니다.

  1. 예제 프로그램 준비 — 프로그램 작성·컴파일, 공통 환경변수 설정
  2. 샘플 키 준비 — 주입해 볼 RSA/EC 키쌍과 AES 키 생성 (보유한 키가 있으면 생략). 애플리케이션이 쓰던 키 객체를 주입하려면 BYOK(키 주입) 키 준비를 참고
  3. 1단계 — wrapping 키쌍 생성과 공개키 반출
  4. AES-256 대칭키 주입 — 주입 후 암호화·복호화 왕복 확인
  5. RSA 키 주입 — 개인키·공개키 주입 후 서명·검증, 암호화·복호화 왕복 확인
  6. EC 키 주입 — 개인키·공개키 주입 후 서명·검증 왕복 확인
  7. 주입한 키 조회 / 정리

특정 키 유형만 필요하시면 1~3을 수행한 뒤 해당 절로 건너뛰셔도 됩니다. 공개키만 주입한다면 wrapping 키쌍이 필요 없으므로 1을 수행한 뒤 RSA 공개키 주입 또는 EC 키 주입의 공개키 부분으로 건너뛰십시오.

이 문서의 절차는 접근이 통제된 서버 한 대에서 실행하는 것을 전제로 합니다. 키를 보유한 쪽의 작업과 HSM 클라이언트 서버의 작업을 같은 서버, 같은 셸에서 순서대로 실행하며, TS 값과 컴파일한 클래스 파일을 그대로 이어서 사용합니다.

각 명령 블록 앞에는 운영 환경에서 그 작업이 어느 쪽에 속하는지를 실행 위치로 표시했습니다. 운영 환경에서 두 시스템을 분리한다면, "키 보유 측 작업"으로 표시된 단계(OpenSSL 감싸기, KwpWrap 실행)는 키를 보유한 시스템에서 수행하고 HSM 클라이언트 서버로는 감싼 결과물(.b64)과 공개키(.pub.pem)만 전달하십시오. 평문 키 파일은 HSM 클라이언트 서버에 두지 마십시오.

:::

지원 환경

전제 조건

연동을 시작하기 전, 다음의 전제 조건을 준수해 주십시오.

  • HSM 클라이언트 서버에 Java가 설치 및 구동되고 있음
  • HSM 클라이언트 서버에 Luna HSM Client(LunaClient)가 설치되어 있음 — LunaClient 설치 가이드
  • HSM 클라이언트 서버 인스턴스와 HSM 파티션 간에 연결 생성 완료
  • HSM 파티션 초기 설정 완료
  • 파티션 정책 확인 완료 — 파티션 정책 요구사항

지원 버전

HSM 클라이언트 서버에 Luna HSM Client(LunaClient)를 설치하십시오. 설치 방법은 LunaClient 설치 가이드를 참고하십시오.

이 문서의 절차는 Linux 서버의 bash를 기준으로 작성했으며, LunaClient 10.9.3에서 확인했습니다.

Java는 JDK 17 이상이 필요합니다. 개인키를 감쌀 때 사용하는 AES/KWP/NoPadding 변환이 JDK 17부터 표준 제공되기 때문입니다. 이 문서의 예제는 OpenJDK 17에서 확인했습니다. 서버에 여러 JDK가 설치되어 있으면 기본 java가 다른 버전을 가리킬 수 있으므로 java -version과 javac -version으로 먼저 확인하십시오. 17이 아니라면 이후 명령의 java/javac를 JDK 17 설치 경로의 것(예: /usr/lib/jvm/java-17-openjdk/bin/java)으로 바꾸어 실행하십시오.

키를 보유한 쪽에서 wrapped blob을 만들 때 OpenSSL을 사용합니다.

예제 프로그램 준비

HsmByok.java 작성

HsmByok.java 파일을 아래 내용으로 생성하십시오. 파티션에 로그인해 키 생성, 주입, 조회, 연산, 삭제를 명령으로 구분해 수행합니다.

코드가 길어 접어 두었습니다. 펼쳐서 전체 내용을 복사해 사용하십시오. 명령별로 어떤 메서드가 무엇을 하는지는 아래 코드 구성에서 먼저 확인할 수 있습니다.

HsmByok.java 전체 코드 펼치기
import com.safenetinc.luna.LunaAPI;
import com.safenetinc.luna.LunaSlotManager;
import com.safenetinc.luna.LunaTokenObject;
import com.safenetinc.luna.attributes.LunaPkcs11Attributes;
import com.safenetinc.luna.provider.LunaProvider;
import com.safenetinc.luna.provider.key.LunaKey;
import com.safenetinc.luna.provider.param.LunaPkcs11AttributesParameterSpec;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Security;
import java.security.Signature;
import java.security.spec.AlgorithmParameterSpec;
import java.security.spec.RSAKeyGenParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

public final class HsmByok {
    private static final String PROVIDER = "LunaProvider";
    private static final String OAEP = "RSA/ECB/OAEPWithSHA256AndMGF1Padding";

    private final int slot;

    private HsmByok(int slot, String pin) {
        Provider provider = new LunaProvider();
        if (Security.getProvider(provider.getName()) == null) {
            Security.addProvider(provider);
        }
        this.slot = slot;
        LunaSlotManager slots = LunaSlotManager.getInstance();
        slots.setDefaultSlot(slot);
        // 파티션 로그인
        if (!slots.login(slot, pin)) {
            throw new IllegalStateException("Partition login failed: slot=" + slot);
        }
    }

    public static void main(String[] args) throws Exception {
        if (args.length == 0 || "help".equals(args[0])) {
            usage();
            return;
        }
        String command = args[0];
        Map<String, String> opt = parseArgs(Arrays.copyOfRange(args, 1, args.length));
        HsmByok app = new HsmByok(Integer.parseInt(required(opt, "--slot")), required(opt, "--pin"));

        switch (command) {
            case "show" -> app.show(opt);
            case "gen-rsa-wrap" -> app.genRsaWrap(opt);
            case "import-public" -> app.importPublic(opt);
            case "unwrap-aes-rsa-oaep" -> app.unwrapAesRsaOaep(opt);
            case "unwrap-key-aes-kwp" -> app.unwrapKeyAesKwp(opt);
            case "aes-cbc-roundtrip" -> app.aesCbcRoundtrip(opt);
            case "rsa-oaep-encrypt" -> app.rsaOaepEncrypt(opt);
            case "rsa-oaep-decrypt" -> app.rsaOaepDecrypt(opt);
            case "sign" -> app.sign(opt);
            case "verify" -> app.verify(opt);
            case "destroy" -> app.destroy(opt);
            default -> {
                System.err.println("Unknown command: " + command);
                usage();
                System.exit(2);
            }
        }
    }

    private static void usage() {
        System.out.println("""
                HsmByok - common options: --slot <slot-id> --pin <CO password>

                  show                --label <label> | --handle <handle>
                  gen-rsa-wrap        --label <base-label> [--bits 2048] --public-out <pem>
                  import-public       --label <label> --key-type RSA|EC --public-in <pem>
                  unwrap-aes-rsa-oaep --unwrapping-label <rsa-private-label> --label <new-label> --in <b64>
                                      [--encrypt true] [--decrypt true] [--unwrap false]
                  unwrap-key-aes-kwp  --unwrapping-label <aes-kek-label> --label <new-label>
                                      --key-type RSA|EC --in <b64> [--sign true] [--decrypt false]
                  aes-cbc-roundtrip   --label <aes-label> [--text <plaintext>]
                  rsa-oaep-encrypt    --public-in <pem> --text <plaintext> --out <b64>   (RSA 전용)
                  rsa-oaep-decrypt    --label <private-key-label> --in <b64>
                  sign                --label <private-key-label> --key-type RSA|EC [--text <message>] --out <b64>
                  verify              --public-in <pem> --key-type RSA|EC [--text <message>] --in <b64>
                  destroy             --label <label>
                """);
    }

    // ---------- 조회 ----------

    private void show(Map<String, String> opt) {
        LunaTokenObject object;
        String target;
        if (opt.containsKey("--handle")) {
            int handle = Integer.parseInt(opt.get("--handle"));
            target = "handle=" + handle;
            object = LunaTokenObject.LocateObjectByHandle(handle, slot);
        } else {
            String label = required(opt, "--label");
            target = "label=" + label;
            object = LunaTokenObject.LocateObjectByAlias(label, slot);
        }
        if (object == null) {
            System.out.println("found=false " + target);
            if (!opt.containsKey("--handle")) {
                System.out.println("  hint: 공개키는 label 로 조회되지 않습니다. --handle 로 조회하십시오.");
            }
            return;
        }
        System.out.printf("found=true %s persistent=%s%n", target, object.IsObjectPersistent());
        long[] attributes = {
                LunaAPI.CKA_TOKEN, LunaAPI.CKA_PRIVATE, LunaAPI.CKA_SENSITIVE, LunaAPI.CKA_EXTRACTABLE,
                LunaAPI.CKA_ENCRYPT, LunaAPI.CKA_DECRYPT, LunaAPI.CKA_SIGN, LunaAPI.CKA_VERIFY,
                LunaAPI.CKA_WRAP, LunaAPI.CKA_UNWRAP
        };
        for (long attribute : attributes) {
            try {
                System.out.printf("  %s=%s%n", attributeName(attribute), object.GetBooleanAttribute(attribute));
            } catch (Exception e) {
                System.out.printf("  %s=(n/a)%n", attributeName(attribute));
            }
        }
    }

    // ---------- 1단계: wrapping 키쌍 생성 ----------

    private void genRsaWrap(Map<String, String> opt) throws Exception {
        String baseLabel = required(opt, "--label");
        int bits = Integer.parseInt(opt.getOrDefault("--bits", "2048"));
        requireLabelFree(baseLabel + "-prv");

        LunaPkcs11Attributes attrs = new LunaPkcs11Attributes();
        // 공개키: wrap 용도 (파티션 밖으로 반출)
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PUBLIC, LunaAPI.CKA_PRIVATE, false);
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PUBLIC, LunaAPI.CKA_WRAP, true);
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PUBLIC, LunaAPI.CKA_ENCRYPT, true);
        // 개인키: unwrap 용도 (파티션 밖으로 나가지 않음)
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_PRIVATE, true);
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_SENSITIVE, true);
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_EXTRACTABLE, false);
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_UNWRAP, true);
        attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_DECRYPT, true);

        AlgorithmParameterSpec params = new LunaPkcs11AttributesParameterSpec(
                attrs, new RSAKeyGenParameterSpec(bits, BigInteger.valueOf(65_537)));
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", PROVIDER);
        generator.initialize(params);
        KeyPair pair = generator.generateKeyPair();

        LunaKey publicKey = (LunaKey) pair.getPublic();
        LunaKey privateKey = (LunaKey) pair.getPrivate();
        // 세션 객체로 생성한 키쌍을 label 을 붙여 한 벌만 영구 저장합니다.
        // (템플릿에 CKA_TOKEN=true 를 넣으면 기본 label 의 영구 객체가 따로 남습니다.)
        publicKey.MakePersistent(baseLabel + "-pub");
        privateKey.MakePersistent(baseLabel + "-prv");

        Path out = Path.of(required(opt, "--public-out"));
        writePem(out, "PUBLIC KEY", publicKey.getEncoded());
        System.out.printf("Generated wrapping key pair: %s-pub / %s-prv%n", baseLabel, baseLabel);
        System.out.println("Public key PEM: " + out);
    }

    // ---------- 공개키 주입 ----------

    private void importPublic(Map<String, String> opt) throws Exception {
        String keyType = keyType(opt);
        byte[] der = readPem(Path.of(required(opt, "--public-in")));
        PublicKey publicKey = KeyFactory.getInstance(keyType, PROVIDER)
                .generatePublic(new X509EncodedKeySpec(der));

        String label = required(opt, "--label");
        LunaTokenObject object = persist((LunaKey) publicKey, label);
        // CKA_PRIVATE 는 조정하지 않습니다. 주입한 공개키 객체에서는 이 속성이
        // CKR_ATTRIBUTE_TYPE_INVALID 로 거부되며, CO 로그인 상태에서 사용하므로
        // private 객체로 남아도 사용에 지장이 없습니다.
        setBool(object, LunaAPI.CKA_VERIFY, bool(opt, "--verify", true));
        if (keyType.equals("RSA")) {
            // 암호화·wrap 은 RSA 공개키에만 해당합니다.
            setBool(object, LunaAPI.CKA_ENCRYPT, bool(opt, "--encrypt", true));
            setBool(object, LunaAPI.CKA_WRAP, bool(opt, "--wrap", false));
        } else {
            // EC 공개키는 서명 검증에만 쓰므로, 기본 템플릿이 켜 두는 암호화·wrap 을 끕니다.
            setBool(object, LunaAPI.CKA_ENCRYPT, false);
            setBool(object, LunaAPI.CKA_WRAP, false);
        }

        System.out.printf("Imported %s public key label=%s handle=%d%n",
                keyType, label, ((LunaKey) publicKey).GetKeyHandle());
    }

    // ---------- 대칭키 주입 (RSA-OAEP unwrap) ----------

    private void unwrapAesRsaOaep(Map<String, String> opt) throws Exception {
        requireLabelFree(required(opt, "--label"));
        LunaKey unwrappingKey = findKey(required(opt, "--unwrapping-label"));
        byte[] wrapped = readBase64(Path.of(required(opt, "--in")));

        Cipher cipher = Cipher.getInstance(OAEP, PROVIDER);
        cipher.init(Cipher.UNWRAP_MODE, unwrappingKey);
        SecretKey key = (SecretKey) cipher.unwrap(wrapped, "AES", Cipher.SECRET_KEY);

        String label = required(opt, "--label");
        LunaTokenObject object = persist((LunaKey) key, label);
        setBool(object, LunaAPI.CKA_ENCRYPT, bool(opt, "--encrypt", true));
        setBool(object, LunaAPI.CKA_DECRYPT, bool(opt, "--decrypt", true));
        setBool(object, LunaAPI.CKA_UNWRAP, bool(opt, "--unwrap", false));
        // unwrap 기본 템플릿이 켜 두는 용도(HMAC 서명·검증, 다른 키 감싸기)를 끕니다.
        setBool(object, LunaAPI.CKA_SIGN, false);
        setBool(object, LunaAPI.CKA_VERIFY, false);
        setBool(object, LunaAPI.CKA_WRAP, false);

        System.out.printf("Imported AES key label=%s%n", label);
    }

    // ---------- 개인키 주입 (AES-KWP unwrap) ----------

    private void unwrapKeyAesKwp(Map<String, String> opt) throws Exception {
        String keyType = keyType(opt);
        requireLabelFree(required(opt, "--label"));
        LunaKey kek = findKey(required(opt, "--unwrapping-label"));
        byte[] wrapped = readBase64(Path.of(required(opt, "--in")));

        Cipher cipher = Cipher.getInstance("AES/KWP/NoPadding", PROVIDER);
        cipher.init(Cipher.UNWRAP_MODE, kek);
        PrivateKey key = (PrivateKey) cipher.unwrap(wrapped, keyType, Cipher.PRIVATE_KEY);

        String label = required(opt, "--label");
        LunaTokenObject object = persist((LunaKey) key, label);
        setBool(object, LunaAPI.CKA_SIGN, bool(opt, "--sign", true));
        setBool(object, LunaAPI.CKA_DECRYPT, bool(opt, "--decrypt", false));
        // unwrap 기본 템플릿이 켜 두는 CKA_UNWRAP 을 끕니다(개인키로 다른 키를 풀 일이 없음).
        setBool(object, LunaAPI.CKA_UNWRAP, false);

        System.out.printf("Imported %s private key label=%s%n", keyType, label);
    }

    // ---------- 연산 ----------

    private void aesCbcRoundtrip(Map<String, String> opt) throws Exception {
        LunaKey key = findKey(required(opt, "--label"));
        byte[] plaintext = opt.getOrDefault("--text", "byok aes test").getBytes(StandardCharsets.UTF_8);
        byte[] iv = new byte[16];
        SecureRandom.getInstanceStrong().nextBytes(iv);

        Cipher encrypt = Cipher.getInstance("AES/CBC/PKCS5Padding", PROVIDER);
        encrypt.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
        byte[] ciphertext = encrypt.doFinal(plaintext);

        Cipher decrypt = Cipher.getInstance("AES/CBC/PKCS5Padding", PROVIDER);
        decrypt.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
        byte[] decrypted = decrypt.doFinal(ciphertext);

        System.out.println("ciphertextBase64=" + Base64.getEncoder().encodeToString(ciphertext));
        System.out.println("roundtripOk=" + Arrays.equals(plaintext, decrypted));
    }

    private void rsaOaepEncrypt(Map<String, String> opt) throws Exception {
        PublicKey key = publicKey(opt);
        byte[] plaintext = required(opt, "--text").getBytes(StandardCharsets.UTF_8);

        Cipher cipher = Cipher.getInstance(OAEP, PROVIDER);
        cipher.init(Cipher.ENCRYPT_MODE, key);
        byte[] ciphertext = cipher.doFinal(plaintext);

        Path out = Path.of(required(opt, "--out"));
        writeBase64(out, ciphertext);
        System.out.println("ciphertextBytes=" + ciphertext.length);
        System.out.println("Output file: " + out);
    }

    private void rsaOaepDecrypt(Map<String, String> opt) throws Exception {
        LunaKey key = findKey(required(opt, "--label"));
        byte[] ciphertext = readBase64(Path.of(required(opt, "--in")));

        Cipher cipher = Cipher.getInstance(OAEP, PROVIDER);
        cipher.init(Cipher.DECRYPT_MODE, (PrivateKey) key);
        byte[] plaintext = cipher.doFinal(ciphertext);

        System.out.println("decryptedText=" + new String(plaintext, StandardCharsets.UTF_8));
    }

    private void sign(Map<String, String> opt) throws Exception {
        LunaKey key = findKey(required(opt, "--label"));
        byte[] message = opt.getOrDefault("--text", "byok signature test").getBytes(StandardCharsets.UTF_8);

        Signature signer = Signature.getInstance(signatureAlgorithm(keyType(opt)), PROVIDER);
        signer.initSign((PrivateKey) key);
        signer.update(message);
        byte[] signature = signer.sign();

        System.out.println("signatureBytes=" + signature.length);
        if (opt.containsKey("--out")) {
            Path out = Path.of(opt.get("--out"));
            writeBase64(out, signature);
            System.out.println("Output file: " + out);
        }
    }

    private void verify(Map<String, String> opt) throws Exception {
        PublicKey key = publicKey(opt);
        byte[] message = opt.getOrDefault("--text", "byok signature test").getBytes(StandardCharsets.UTF_8);
        byte[] signature = readBase64(Path.of(required(opt, "--in")));

        Signature verifier = Signature.getInstance(
                signatureAlgorithm(keyType(opt)), PROVIDER);
        verifier.initVerify(key);
        verifier.update(message);

        System.out.println("verified=" + verifier.verify(signature));
    }

    private void destroy(Map<String, String> opt) {
        LunaKey key = findKey(required(opt, "--label"));
        int handle = key.GetKeyHandle();
        key.DestroyKey();
        System.out.printf("Destroyed handle=%d%n", handle);
    }

    // ---------- 유틸 ----------

    /**
     * 공개키를 얻습니다. PEM 을 지정하면 LunaProvider 세션 객체로 올려서 사용하므로
     * 연산 자체는 HSM 안에서 수행됩니다. 주입한 공개키는 label 로 조회되지 않으므로
     * PEM 지정을 기본 경로로 삼으십시오.
     */
    private PublicKey publicKey(Map<String, String> opt) throws Exception {
        if (opt.containsKey("--public-in")) {
            byte[] der = readPem(Path.of(opt.get("--public-in")));
            return (PublicKey) KeyFactory.getInstance(keyType(opt), PROVIDER)
                    .generatePublic(new X509EncodedKeySpec(der));
        }
        return (PublicKey) findKey(required(opt, "--label"));
    }

    /**
     * 같은 label 의 키가 이미 있으면 중단합니다. label 은 중복이 허용되므로, 다시 실행했을 때
     * 같은 label 의 객체가 쌓여 이후 label 조회가 어느 쪽을 가리킬지 알 수 없게 되는 것을 막습니다.
     * 공개키는 label 로 조회되지 않으므로 이 검사로 막을 수 없습니다.
     */
    private void requireLabelFree(String label) {
        if (LunaKey.LocateKeyByAlias(label, slot) != null) {
            throw new IllegalStateException("Key already exists: label=" + label
                    + " slot=" + slot + " (destroy it first, or use a new label)");
        }
    }

    private LunaKey findKey(String label) {
        LunaKey key = LunaKey.LocateKeyByAlias(label, slot);
        if (key == null) {
            throw new IllegalStateException("Key not found: label=" + label + " slot=" + slot);
        }
        return key;
    }

    /**
     * 토큰(영구) 객체로 기록하고 그 객체를 반환합니다.
     * label 조회는 공개키를 찾지 못하므로, MakePersistent 가 갱신한 핸들로 조회합니다.
     */
    private LunaTokenObject persist(LunaKey key, String label) {
        key.MakePersistent(label);
        int handle = key.GetKeyHandle();
        LunaTokenObject object = LunaTokenObject.LocateObjectByHandle(handle, slot);
        if (object == null) {
            throw new IllegalStateException(
                    "Key was not persisted as a token object: label=" + label + " slot=" + slot);
        }
        return object;
    }

    private void setBool(LunaTokenObject object, long attribute, boolean value) {
        // 용도 속성을 지정하지 못한 키는 의도와 다른 권한으로 남으므로 여기서 중단합니다.
        // 파티션 정책 11(Allow changing key attributes)이 꺼져 있으면 이 단계에서 실패합니다.
        long rv;
        try {
            rv = object.SetBooleanAttribute(attribute, value);
        } catch (Exception e) {
            throw new IllegalStateException(String.format(
                    "Failed to set attribute=%s value=%s on handle=%d (%s)",
                    attributeName(attribute), value, object.GetHandle(), e.getMessage()), e);
        }
        if (rv != 0) {
            throw new IllegalStateException(String.format(
                    "Failed to set attribute=%s value=%s on handle=%d (rv=0x%x)",
                    attributeName(attribute), value, object.GetHandle(), rv));
        }
    }

    private static String attributeName(long attribute) {
        if (attribute == LunaAPI.CKA_TOKEN) return "CKA_TOKEN";
        if (attribute == LunaAPI.CKA_PRIVATE) return "CKA_PRIVATE";
        if (attribute == LunaAPI.CKA_SENSITIVE) return "CKA_SENSITIVE";
        if (attribute == LunaAPI.CKA_EXTRACTABLE) return "CKA_EXTRACTABLE";
        if (attribute == LunaAPI.CKA_ENCRYPT) return "CKA_ENCRYPT";
        if (attribute == LunaAPI.CKA_DECRYPT) return "CKA_DECRYPT";
        if (attribute == LunaAPI.CKA_SIGN) return "CKA_SIGN";
        if (attribute == LunaAPI.CKA_VERIFY) return "CKA_VERIFY";
        if (attribute == LunaAPI.CKA_WRAP) return "CKA_WRAP";
        if (attribute == LunaAPI.CKA_UNWRAP) return "CKA_UNWRAP";
        return "0x" + Long.toHexString(attribute);
    }

    /** --key-type 을 RSA 또는 EC 로 정규화합니다. 지정하지 않으면 RSA 입니다. */
    private static String keyType(Map<String, String> opt) {
        String value = opt.getOrDefault("--key-type", "RSA").toUpperCase();
        if (value.equals("ECDSA")) {
            value = "EC";
        }
        if (!value.equals("RSA") && !value.equals("EC")) {
            throw new IllegalArgumentException("--key-type must be RSA or EC");
        }
        return value;
    }

    private static String signatureAlgorithm(String keyType) {
        return switch (keyType.toUpperCase()) {
            case "RSA" -> "SHA256withRSA";
            case "EC", "ECDSA" -> "SHA256withECDSA";
            default -> throw new IllegalArgumentException("Unsupported key type: " + keyType);
        };
    }

    private static boolean bool(Map<String, String> opt, String name, boolean defaultValue) {
        return opt.containsKey(name) ? Boolean.parseBoolean(opt.get(name)) : defaultValue;
    }

    private static Map<String, String> parseArgs(String[] args) {
        Map<String, String> opt = new LinkedHashMap<>();
        for (int i = 0; i < args.length; i++) {
            if (!args[i].startsWith("--")) {
                throw new IllegalArgumentException("Expected option name, got: " + args[i]);
            }
            if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
                opt.put(args[i], "true");
            } else {
                opt.put(args[i], args[++i]);
            }
        }
        return opt;
    }

    private static String required(Map<String, String> opt, String key) {
        String value = opt.get(key);
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Missing required option: " + key);
        }
        return value;
    }

    private static void writePem(Path out, String type, byte[] der) throws IOException {
        String body = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(der);
        Files.writeString(out, "-----BEGIN " + type + "-----\n" + body + "\n-----END " + type + "-----\n",
                StandardCharsets.US_ASCII);
    }

    private static byte[] readPem(Path in) throws IOException {
        String text = Files.readString(in, StandardCharsets.US_ASCII)
                .replaceAll("-----(BEGIN|END)[^-]*-----", "")
                .replaceAll("\\s+", "");
        return Base64.getDecoder().decode(text);
    }

    private static void writeBase64(Path out, byte[] value) throws IOException {
        Files.writeString(out,
                Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(value) + "\n",
                StandardCharsets.US_ASCII);
    }

    private static byte[] readBase64(Path in) throws IOException {
        return Base64.getDecoder().decode(
                Files.readString(in, StandardCharsets.US_ASCII).replaceAll("\\s+", ""));
    }
}

:::(info) (참고)
LunaProvider에서 파티션에 로그인하는 방법은 KeyStore.load()와 LunaSlotManager.login() 두 가지가 있습니다. 이 예제는 후자를 사용합니다. 슬롯 번호와 비밀번호만으로 로그인할 수 있고, 키 속성을 handle 단위로 다루는 LunaTokenObject와 함께 쓰기 쉽기 때문입니다.

:::

HsmByok.java 코드 구성

HsmByok은 생성자에서 LunaProvider를 등록하고 파티션에 로그인(LunaSlotManager.login)한 뒤, 명령별 메서드를 실행합니다. 키 생성·unwrap·연산은 모두 표준 JCA API(KeyPairGenerator, Cipher, KeyFactory, Signature)에 Provider로 LunaProvider를 지정해 호출하므로, 실제 처리는 HSM 안에서 이루어집니다. 애플리케이션에 옮길 때는 필요한 명령의 메서드만 가져다 쓰면 됩니다.

명령 메서드 하는 일 핵심 API
gen-rsa-wrap genRsaWrap 파티션 안에서 wrapping 키쌍을 생성하고 공개키만 PEM으로 반출 KeyPairGenerator.getInstance("RSA", "LunaProvider") + 속성 템플릿(LunaPkcs11AttributesParameterSpec)
unwrap-aes-rsa-oaep unwrapAesRsaOaep RSA-OAEP로 감싼 AES 키(또는 임시 KEK)를 파티션 안에서 풀어 저장 Cipher.getInstance("RSA/ECB/OAEPWithSHA256AndMGF1Padding", "LunaProvider"), cipher.unwrap(..., Cipher.SECRET_KEY)
unwrap-key-aes-kwp unwrapKeyAesKwp 임시 KEK로 AES-KWP 감싼 RSA/EC 개인키를 파티션 안에서 풀어 저장 Cipher.getInstance("AES/KWP/NoPadding", "LunaProvider"), cipher.unwrap(..., Cipher.PRIVATE_KEY)
import-public importPublic 공개키 PEM을 파티션에 저장 KeyFactory.getInstance(..., "LunaProvider").generatePublic(...)
show show label 또는 handle로 객체를 찾아 속성 출력 LunaTokenObject.LocateObjectByAlias / LocateObjectByHandle, GetBooleanAttribute
aes-cbc-roundtrip, rsa-oaep-encrypt, rsa-oaep-decrypt, sign, verify 같은 이름 주입한 키로 연산 Cipher / Signature + LunaProvider
destroy destroy label로 찾은 키 삭제 LunaKey.DestroyKey

주입 명령은 공통으로 세 유틸 메서드를 거칩니다.

  • persist — unwrap이나 generatePublic으로 만들어진 키는 세션 객체라 세션이 끝나면 사라집니다. MakePersistent(label)로 파티션에 영구 저장(토큰 객체)한 뒤, 갱신된 handle로 객체를 다시 찾아 돌려줍니다.
  • setBool — 저장한 객체에 용도 속성(CKA_ENCRYPT, CKA_SIGN 등)을 지정합니다. 지정에 실패하면 의도와 다른 권한의 키가 남으므로 그 자리에서 중단합니다. 주입 명령은 사용하지 않는 용도 속성(대칭키의 CKA_SIGN/CKA_VERIFY/CKA_WRAP, 개인키의 CKA_UNWRAP)도 이 메서드로 끕니다.
  • requireLabelFree — 생성·주입 전에 같은 label의 키가 이미 있는지 확인하고, 있으면 중단합니다. label은 중복이 허용되므로 같은 명령을 다시 실행하면 같은 label의 객체가 쌓이고, 이후 label 조회가 어느 쪽을 가리킬지 알 수 없게 됩니다. 공개키는 label로 조회되지 않으므로 이 검사로 막지 못합니다.

KwpWrap.java 작성

개인키를 주입할 때 개인키를 임시 AES 키로 감싸는 데 사용하는 헬퍼입니다. 이 예제에서는 HsmByok.java와 같은 서버에서 컴파일해 사용합니다. LunaProvider가 필요 없으므로, 운영 환경에서 두 시스템을 분리한다면 키를 보유한 시스템에서 컴파일·실행하면 됩니다. 공개키와 대칭키만 주입한다면 이 파일은 필요하지 않습니다.

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.security.KeyFactory;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;

public class KwpWrap {
    public static void main(String[] args) throws Exception {
        if (args.length != 4) {
            throw new IllegalArgumentException("usage: KwpWrap RSA|EC <kek.bin> <pkcs8.der> <out.b64>");
        }
        byte[] kek = Files.readAllBytes(Path.of(args[1]));
        byte[] pkcs8 = Files.readAllBytes(Path.of(args[2]));
        Key key = KeyFactory.getInstance(args[0]).generatePrivate(new PKCS8EncodedKeySpec(pkcs8));

        Cipher cipher = Cipher.getInstance("AES/KWP/NoPadding");
        cipher.init(Cipher.WRAP_MODE, new SecretKeySpec(kek, "AES"));
        byte[] wrapped = cipher.wrap(key);

        Files.writeString(Path.of(args[3]),
                Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(wrapped) + "\n");
        System.out.println("wrappedBytes=" + wrapped.length);
    }
}

컴파일

실행 위치 — HSM 클라이언트 서버

javac -encoding UTF-8 -cp /usr/safenet/lunaclient/jsp/lib/LunaProvider.jar HsmByok.java
javac -encoding UTF-8 KwpWrap.java

운영 환경에서 두 시스템을 분리한다면 KwpWrap.java는 키 보유 측 시스템에서 컴파일하십시오. LunaProvider가 필요 없으므로 JDK 17만 있으면 됩니다.

:::(warning) (주의)
소스 파일을 UTF-8로 저장하고, javac에 -encoding UTF-8을 반드시 지정하십시오. 위 소스에는 한글 주석이 들어 있는데, 서버 로케일이 C/POSIX인 경우 javac의 기본 인코딩이 US-ASCII가 되어 error: unmappable character 로 컴파일이 실패합니다.

:::

실행 공통값

실행 위치 — HSM 클라이언트 서버

이후의 모든 명령에서 사용할 값입니다. SLOT과 CO_PW를 실제 파티션의 슬롯 번호와 Crypto Officer(CO) 비밀번호로 교체하십시오. 키 생성·주입·삭제는 CO 권한이 필요합니다. Crypto User(CU)로는 키를 조회할 수는 있지만 생성·주입할 수 없어 이 문서의 절차를 수행할 수 없으며, HsmByok이 사용하는 LunaSlotManager.login(slot, pin)도 CO로 로그인합니다.

export SLOT=0
read -rsp "CO password: " CO_PW; echo; export CO_PW
export TS=${TS:-$(date +%Y%m%d%H%M%S)}; echo "TS=$TS"

export BYOK="java -cp $PWD:/usr/safenet/lunaclient/jsp/lib/LunaProvider.jar \
  -Djava.library.path=/usr/safenet/lunaclient/jsp/lib HsmByok"

CO_PW는 화면과 셸 기록에 남지 않도록 입력받습니다. TS는 이 문서의 예제에서 label과 파일 이름이 겹치지 않도록 붙이는 타임스탬프입니다. 이미 TS가 설정되어 있으면(예: BYOK(키 주입) 키 준비를 먼저 진행한 경우) 그 값을 그대로 사용합니다. 아래 절차를 이어서 진행하는 동안 값을 바꾸지 마십시오. 출력된 TS 값은 기록해 두십시오. 셸을 새로 열거나 키 보유 측을 다른 시스템에서 작업할 때는 export TS=<기록한 값>으로 같은 값을 지정해야 파일 이름이 맞습니다.

슬롯 번호는 lunacm:> slot list로 확인할 수 있습니다. HA로 구성된 경우 lunacm이 표시하는 번호와 Java·cmu가 사용하는 슬롯 번호가 다를 수 있으므로, 아래 show 명령으로 대상 파티션에 로그인되는지 확인하십시오.

:::(warning) (주의)
HA(고가용성) 그룹으로 구성된 경우 SLOT에는 개별 파티션 슬롯이 아니라 HA 가상 슬롯 ID를 지정해야 합니다. 개별 슬롯에 주입하면 그 파티션에만 키가 생기고 HA 멤버 간에 동기화되지 않습니다.

:::

:::(warning) (주의)
로그인 실패가 이어지면 Crypto Officer 역할이 잠길 수 있습니다. 허용 횟수는 파티션 정책 20(Max failed user logins allowed)으로 정해집니다. 비밀번호가 틀렸다는 오류가 나면 비밀번호를 확인한 뒤 다시 시도하십시오.

:::

명령 목록이 출력되는지 확인한 뒤, 파티션 로그인까지 되는지 확인합니다. 존재하지 않는 label을 조회하므로 found=false가 출력되면 정상입니다. PIN is incorrect가 나오면 문제 해결을 참고하십시오.

$BYOK help
$BYOK show --slot "$SLOT" --pin "$CO_PW" --label "login-check-$TS"
found=false label=login-check-20260806103000
  hint: 공개키는 label 로 조회되지 않습니다. --handle 로 조회하십시오.

샘플 키 준비

이 문서의 절차를 그대로 따라 해 볼 수 있도록, 주입할 샘플 키를 만듭니다. 실제로 주입할 키를 이미 파일로 보유하고 계시면 이 절을 건너뛰고 이후 명령에서 # 준비한 키 사용 시 주석이 달린 줄의 파일 경로만 보유하신 키 파일로 바꾸시면 됩니다. 파일 형식은 주입 입력 형식을 참고하십시오. 키가 파일이 아니라 애플리케이션 코드의 키 객체(SecretKey, KeyPair, KeyStore)로만 있다면 BYOK(키 주입) 키 준비를 참고해 파일로 내보내십시오.

실행 위치 — 키 보유 측 작업 (이 예제에서는 같은 서버에서 실행)

umask 077   # 이 셸에서 만드는 파일을 소유자만 읽고 쓸 수 있게 함

# RSA-2048 키쌍 — 개인키, 공개키, 주입용 PKCS#8 DER
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "/tmp/sample-rsa-$TS.pem"
openssl rsa -in "/tmp/sample-rsa-$TS.pem" -pubout -out "/tmp/sample-rsa-$TS.pub.pem"
openssl pkcs8 -topk8 -nocrypt \
  -in "/tmp/sample-rsa-$TS.pem" -outform DER -out "/tmp/sample-rsa-$TS.pk8.der"

# EC P-256 키쌍 — 개인키, 공개키, 주입용 PKCS#8 DER
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "/tmp/sample-ec-$TS.pem"
openssl ec -in "/tmp/sample-ec-$TS.pem" -pubout -out "/tmp/sample-ec-$TS.pub.pem"
openssl pkcs8 -topk8 -nocrypt \
  -in "/tmp/sample-ec-$TS.pem" -outform DER -out "/tmp/sample-ec-$TS.pk8.der"

# AES-256 대칭키 (32바이트)
openssl rand -out "/tmp/sample-aes-$TS.bin" 32

ls -l /tmp/sample-*-$TS.*
파일 역할
sample-rsa-$TS.pem RSA 개인키 평문. 감싸는 데만 쓰고 전달하지 않습니다.
sample-rsa-$TS.pub.pem RSA 공개키. 주입과 서명 검증·암호화에 사용합니다.
sample-rsa-$TS.pk8.der RSA 개인키 PKCS#8 DER. 감싸기 입력입니다.
sample-ec-$TS.pem EC 개인키 평문.
sample-ec-$TS.pub.pem EC 공개키.
sample-ec-$TS.pk8.der EC 개인키 PKCS#8 DER.
sample-aes-$TS.bin AES-256 키 평문 32바이트.

:::(warning) (주의)
.pem, .pk8.der, .bin 은 키 평문입니다. 이 예제는 한 서버에서 실행하므로 같은 서버에 생성되지만, 운영 환경에서 두 시스템을 분리한다면 이 파일들을 HSM 클라이언트 서버로 옮기지 마십시오. HSM 쪽으로 넘기는 것은 감싼 결과물(.b64)과 공개키(.pub.pem)뿐입니다. 모든 절차가 끝나면 정리에서 삭제하십시오.

:::

1단계 — wrapping 키쌍 생성과 공개키 반출

공개키만 주입하는 경우를 제외하면, 모든 주입 시나리오는 이 단계에서 시작합니다. 파티션 안에 RSA wrapping 키쌍을 만들고 공개키만 PEM으로 반출합니다.

실행 위치 — HSM 클라이언트 서버

$BYOK gen-rsa-wrap \
  --slot "$SLOT" --pin "$CO_PW" \
  --label "byok-wrap-$TS" \
  --bits 2048 \
  --public-out "/tmp/byok-wrap-$TS.pem"

다음과 같은 결과가 출력되면 정상입니다.

Generated wrapping key pair: byok-wrap-20260806103000-pub / byok-wrap-20260806103000-prv
Public key PEM: /tmp/byok-wrap-20260806103000.pem

이 명령이 하는 일 (genRsaWrap)

// 개인키: 파티션 밖으로 꺼낼 수 없고(EXTRACTABLE=false), unwrap에만 쓰는 키로 생성
attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_EXTRACTABLE, false);
attrs.setBooleanAttribute(LunaPkcs11Attributes.PRIVATE, LunaAPI.CKA_UNWRAP, true);
// ...
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", PROVIDER);   // PROVIDER = "LunaProvider"
generator.initialize(params);                      // params = 위 속성 템플릿 + 2048비트, 공개 지수 65537
KeyPair pair = generator.generateKeyPair();        // 키쌍은 HSM 안에서 생성
publicKey.MakePersistent(baseLabel + "-pub");
privateKey.MakePersistent(baseLabel + "-prv");
writePem(out, "PUBLIC KEY", publicKey.getEncoded());   // 공개키만 PEM으로 반출

키쌍을 HSM 안에서 생성하므로 wrapping 개인키는 처음부터 파티션 밖에 존재하지 않습니다. 키 보유 측은 반출한 공개키로 키를 감싸고, 그 결과는 이 개인키가 있는 파티션 안에서만 풀 수 있습니다.

이 예제에서는 같은 서버에서 이어서 사용하므로 별도 전달이 필요 없습니다. 운영 환경에서 두 시스템을 분리한다면 생성된 /tmp/byok-wrap-$TS.pem 파일을 키 보유 측에 전달하십시오. 공개키이므로 별도의 보호 없이 전달해도 됩니다. 반면 -prv 개인키는 파티션 밖으로 나가지 않습니다.

AES-256 대칭키 주입

AES 대칭키는 HSM wrapping 공개키로 한 번 감싸서 주입합니다.

키 보유 측 — AES 키 감싸기

실행 위치 — 키 보유 측 작업 (이 예제에서는 같은 서버에서 실행)

# 준비한 키 사용 시: -in 을 보유한 AES-256 키 파일(원시 32바이트)로
openssl pkeyutl -encrypt \
  -pubin -inkey "/tmp/byok-wrap-$TS.pem" \
  -in "/tmp/sample-aes-$TS.bin" \
  -out "/tmp/sample-aes-$TS.oaep.bin" \
  -pkeyopt rsa_padding_mode:oaep \
  -pkeyopt rsa_oaep_md:sha256 \
  -pkeyopt rsa_mgf1_md:sha256

base64 "/tmp/sample-aes-$TS.oaep.bin" > "/tmp/sample-aes-$TS.oaep.b64"

:::(warning) (주의)
rsa_oaep_md와 rsa_mgf1_md를 모두 sha256으로 지정해야 합니다. OpenSSL 기본값은 SHA-1이라, 생략하면 HSM에서 unwrap이 실패합니다.

:::

운영 환경에서 두 시스템을 분리한다면 /tmp/sample-aes-$TS.oaep.b64 파일만 HSM 클라이언트 서버로 전달하고, /tmp/sample-aes-$TS.bin 평문 파일은 전달하지 마십시오.

파티션에 주입

실행 위치 — HSM 클라이언트 서버

$BYOK unwrap-aes-rsa-oaep \
  --slot "$SLOT" --pin "$CO_PW" \
  --unwrapping-label "byok-wrap-$TS-prv" \
  --label "aes-$TS" \
  --in "/tmp/sample-aes-$TS.oaep.b64" \
  --encrypt true --decrypt true --unwrap false
Imported AES key label=aes-20260806103000

이 명령이 하는 일 (unwrapAesRsaOaep)

LunaKey unwrappingKey = findKey(required(opt, "--unwrapping-label"));   // 파티션 안의 wrapping 개인키
Cipher cipher = Cipher.getInstance(OAEP, PROVIDER);   // RSA/ECB/OAEPWithSHA256AndMGF1Padding
cipher.init(Cipher.UNWRAP_MODE, unwrappingKey);
SecretKey key = (SecretKey) cipher.unwrap(wrapped, "AES", Cipher.SECRET_KEY);

LunaTokenObject object = persist((LunaKey) key, label);       // 파티션에 영구 저장
setBool(object, LunaAPI.CKA_ENCRYPT, bool(opt, "--encrypt", true));
setBool(object, LunaAPI.CKA_DECRYPT, bool(opt, "--decrypt", true));
setBool(object, LunaAPI.CKA_UNWRAP, bool(opt, "--unwrap", false));
setBool(object, LunaAPI.CKA_SIGN, false);                     // 기본 템플릿이 켜 두는 용도 끄기
setBool(object, LunaAPI.CKA_VERIFY, false);
setBool(object, LunaAPI.CKA_WRAP, false);

cipher.unwrap은 감싼 키(wrapped)를 HSM으로 보내고, HSM이 파티션 안의 wrapping 개인키로 풀어 그 자리에 AES 키 객체를 만듭니다. HSM 클라이언트 서버가 돌려받는 것은 키 객체의 handle뿐이며, AES 키 평문은 서버 메모리에도 나타나지 않습니다.

여러 개의 AES 키를 주입하려면 키마다 --label을 다르게 지정해 이 절차를 반복하십시오. wrapping 키쌍은 재사용할 수 있습니다.

암호화·복호화 왕복 테스트

실행 위치 — HSM 클라이언트 서버

$BYOK aes-cbc-roundtrip \
  --slot "$SLOT" --pin "$CO_PW" \
  --label "aes-$TS" \
  --text "byok aes test"
ciphertextBase64={ciphertext}
roundtripOk=true

roundtripOk=true는 HSM 안에서 암호화한 결과를 같은 키로 복호화해 원문이 그대로 복원됐다는 뜻입니다.

RSA 키 주입

RSA는 개인키와 공개키를 모두 주입한 뒤, 서명·검증과 암호화·복호화를 각각 왕복으로 확인합니다.

개인키는 크기 때문에 RSA-OAEP로 직접 감쌀 수 없습니다. 임시 AES KEK로 개인키를 감싸고, 그 KEK만 HSM 공개키로 감싸서 전달합니다. 파티션 안에서는 unwrap을 두 번 수행합니다.

키 보유 측 — 임시 KEK 생성과 개인키 감싸기

실행 위치 — 키 보유 측 작업 (이 예제에서는 같은 서버에서 실행)

# 1. 임시 AES-256 KEK 생성
openssl rand -out "/tmp/kek-$TS.bin" 32

# 2. KEK 를 HSM 공개키로 RSA-OAEP 감싸기
openssl pkeyutl -encrypt \
  -pubin -inkey "/tmp/byok-wrap-$TS.pem" \
  -in "/tmp/kek-$TS.bin" \
  -out "/tmp/kek-$TS.oaep.bin" \
  -pkeyopt rsa_padding_mode:oaep \
  -pkeyopt rsa_oaep_md:sha256 \
  -pkeyopt rsa_mgf1_md:sha256
base64 "/tmp/kek-$TS.oaep.bin" > "/tmp/kek-$TS.oaep.b64"

# 3. RSA 개인키를 KEK 로 AES-KWP 감싸기
# 준비한 키 사용 시: 둘째 인자를 보유한 RSA 개인키 PKCS#8 DER 파일로
java KwpWrap RSA \
  "/tmp/kek-$TS.bin" \
  "/tmp/sample-rsa-$TS.pk8.der" \
  "/tmp/sample-rsa-$TS.kwp.b64"
wrappedBytes=1232

운영 환경에서 두 시스템을 분리한다면 HSM 클라이언트 서버로는 kek-$TS.oaep.b64, sample-rsa-$TS.kwp.b64, sample-rsa-$TS.pub.pem 세 파일만 전달합니다.

파티션에 임시 KEK 주입

KEK는 개인키를 풀기 위한 용도로만 쓰므로 --unwrap true만 켜고 암복호화 권한은 끕니다.

실행 위치 — HSM 클라이언트 서버

$BYOK unwrap-aes-rsa-oaep \
  --slot "$SLOT" --pin "$CO_PW" \
  --unwrapping-label "byok-wrap-$TS-prv" \
  --label "kek-$TS" \
  --in "/tmp/kek-$TS.oaep.b64" \
  --encrypt false --decrypt false --unwrap true
Imported AES key label=kek-20260806103000

파티션에 RSA 개인키 주입

서명과 RSA-OAEP 복호화에 모두 사용하므로 --sign true --decrypt true를 지정합니다.

실행 위치 — HSM 클라이언트 서버

$BYOK unwrap-key-aes-kwp \
  --slot "$SLOT" --pin "$CO_PW" \
  --unwrapping-label "kek-$TS" \
  --label "rsa-prv-$TS" \
  --key-type RSA \
  --in "/tmp/sample-rsa-$TS.kwp.b64" \
  --sign true --decrypt true
Imported RSA private key label=rsa-prv-20260806103000

이 명령이 하는 일 (unwrapKeyAesKwp)

LunaKey kek = findKey(required(opt, "--unwrapping-label"));   // 바로 앞에서 주입한 임시 KEK
Cipher cipher = Cipher.getInstance("AES/KWP/NoPadding", PROVIDER);
cipher.init(Cipher.UNWRAP_MODE, kek);
PrivateKey key = (PrivateKey) cipher.unwrap(wrapped, keyType, Cipher.PRIVATE_KEY);

LunaTokenObject object = persist((LunaKey) key, label);
setBool(object, LunaAPI.CKA_SIGN, bool(opt, "--sign", true));
setBool(object, LunaAPI.CKA_DECRYPT, bool(opt, "--decrypt", false));
setBool(object, LunaAPI.CKA_UNWRAP, false);                    // 기본 템플릿이 켜 두는 CKA_UNWRAP 끄기

개인키 주입은 unwrap을 두 번 수행합니다. 첫 번째로 임시 KEK 주입에서 RSA-OAEP로 감싼 KEK를 wrapping 개인키로 풀어 파티션에 저장하고, 두 번째로 이 명령에서 AES-KWP로 감싼 개인키를 그 KEK로 풉니다. 두 단계 모두 HSM 안에서 수행되므로 KEK와 개인키의 평문은 HSM 클라이언트 서버에 나타나지 않습니다. 개인키를 RSA-OAEP로 바로 감싸지 않는 이유는, RSA-OAEP로 감쌀 수 있는 크기(RSA-2048 기준 최대 190바이트)보다 개인키가 크기 때문입니다.

파티션에 RSA 공개키 주입

공개키는 공개 정보이므로 감쌀 필요 없이 PEM 파일을 그대로 주입합니다.

실행 위치 — HSM 클라이언트 서버

# 준비한 키 사용 시: --public-in 을 보유한 RSA 공개키 PEM 파일로
$BYOK import-public \
  --slot "$SLOT" --pin "$CO_PW" \
  --key-type RSA \
  --label "rsa-pub-$TS" \
  --public-in "/tmp/sample-rsa-$TS.pub.pem"
Imported RSA public key label=rsa-pub-20260806103000 handle={handle}

이 명령이 하는 일 (importPublic)

byte[] der = readPem(Path.of(required(opt, "--public-in")));
PublicKey publicKey = KeyFactory.getInstance(keyType, PROVIDER)
        .generatePublic(new X509EncodedKeySpec(der));     // 파티션에 세션 객체로 생성
LunaTokenObject object = persist((LunaKey) publicKey, label);   // 영구 저장
setBool(object, LunaAPI.CKA_VERIFY, bool(opt, "--verify", true));

공개키는 비밀이 아니므로 unwrap 없이 PEM을 그대로 파티션 객체로 만듭니다.

공개키는 label로 조회되지 않으므로 확인할 때 handle이 필요합니다. 출력된 handle은 이 명령을 실행한 프로세스 안에서만 유효하므로 기록해 두었다가 쓰지 마십시오. 확인할 때마다 cmu list로 label과 handle을 다시 조회하고, 그 handle을 바로 사용하십시오.

/usr/safenet/lunaclient/bin/cmu list -slot="$SLOT" -password="$CO_PW" -display=handle,label </dev/null | grep -- "rsa-pub-$TS"
$BYOK show --slot "$SLOT" --pin "$CO_PW" --handle <위에서 조회한 handle>
found=true handle={handle} persistent=true
  CKA_TOKEN=true
  CKA_PRIVATE=true
  CKA_SENSITIVE=(n/a)
  CKA_EXTRACTABLE=(n/a)
  CKA_ENCRYPT=true
  CKA_DECRYPT=(n/a)
  CKA_SIGN=(n/a)
  CKA_VERIFY=true
  CKA_WRAP=false
  CKA_UNWRAP=(n/a)

(n/a)는 공개키 객체에 존재하지 않는 속성이라는 뜻입니다.

:::(warning) (주의)
주입한 공개키는 파티션에 정상 보관되지만, LunaProvider의 label 조회로는 찾을 수 없습니다. LunaProvider의 label 기반 탐색 API는 개인키·대칭키·인증서만 대상으로 하며 공개키 객체는 검색하지 않습니다. 파티션 안에서 생성한 공개키도 마찬가지입니다.

따라서 아래 검증·암호화 명령은 label 대신 공개키 PEM을 지정합니다. 이때 LunaProvider가 공개키를 세션 객체로 파티션에 올리므로 연산 자체는 HSM 안에서 수행되며, 보안상 차이는 없습니다. 공개키에는 보호할 비밀이 없으므로 영구 보관은 인벤토리 목적입니다.

:::

:::(info) (참고)
주입할 공개키 PEM이 -----BEGIN PUBLIC KEY-----(SubjectPublicKeyInfo)로 시작하는지 확인하십시오. -----BEGIN RSA PUBLIC KEY-----(PKCS#1)로 시작한다면 그대로 사용할 수 없으므로, 그 경우에만 아래 명령으로 변환한 뒤 주입합니다. 위 샘플 키 준비에서 만든 PEM은 이미 SubjectPublicKeyInfo 형식이므로 변환이 필요 없습니다.

openssl rsa -RSAPublicKey_in -in pkcs1-pub.pem -pubout -out spki-pub.pem

:::

서명·검증 왕복 테스트

주입한 개인키로 서명하고, 같은 키쌍의 공개키로 검증합니다.

실행 위치 — HSM 클라이언트 서버

$BYOK sign \
  --slot "$SLOT" --pin "$CO_PW" \
  --label "rsa-prv-$TS" \
  --key-type RSA \
  --text "byok signature test" \
  --out "/tmp/rsa-sig-$TS.b64"

# 준비한 키 사용 시: --public-in 을 보유한 RSA 공개키 PEM 파일로
$BYOK verify \
  --slot "$SLOT" --pin "$CO_PW" \
  --key-type RSA \
  --public-in "/tmp/sample-rsa-$TS.pub.pem" \
  --text "byok signature test" \
  --in "/tmp/rsa-sig-$TS.b64"

RSA-2048 키의 서명은 항상 256바이트입니다.

signatureBytes=256
Output file: /tmp/rsa-sig-20260806103000.b64
verified=true

verified=true는 주입한 개인키가 만든 서명이 원본 공개키로 검증됐다는 뜻이며, 개인키가 손상 없이 주입됐음을 확인해 줍니다.

암호화·복호화 왕복 테스트

공개키로 RSA-OAEP 암호화하고, 주입한 개인키로 복호화합니다.

실행 위치 — HSM 클라이언트 서버

# 준비한 키 사용 시: --public-in 을 보유한 RSA 공개키 PEM 파일로
$BYOK rsa-oaep-encrypt \
  --slot "$SLOT" --pin "$CO_PW" \
  --public-in "/tmp/sample-rsa-$TS.pub.pem" \
  --text "byok oaep test" \
  --out "/tmp/oaep-ct-$TS.b64"

$BYOK rsa-oaep-decrypt \
  --slot "$SLOT" --pin "$CO_PW" \
  --label "rsa-prv-$TS" \
  --in "/tmp/oaep-ct-$TS.b64"
ciphertextBytes=256
Output file: /tmp/oaep-ct-20260806103000.b64
decryptedText=byok oaep test

EC 키 주입

EC도 RSA와 동일한 이중 wrap 방식으로 개인키를 주입하고, 공개키는 그대로 주입합니다. --key-type만 EC로 바꾸면 됩니다. EC 공개키로는 암호화를 수행하지 않으므로 서명·검증만 확인합니다.

RSA 키 주입에서 만든 임시 KEK(kek-$TS)를 그대로 재사용합니다. EC만 주입하시는 경우에는 키 보유 측 — 임시 KEK 생성과 개인키 감싸기의 1·2번과 파티션에 임시 KEK 주입을 먼저 수행하십시오.

키 보유 측 — 개인키 감싸기

실행 위치 — 키 보유 측 작업 (이 예제에서는 같은 서버에서 실행)

# 준비한 키 사용 시: 둘째 인자를 보유한 EC 개인키 PKCS#8 DER 파일로
java KwpWrap EC \
  "/tmp/kek-$TS.bin" \
  "/tmp/sample-ec-$TS.pk8.der" \
  "/tmp/sample-ec-$TS.kwp.b64"
wrappedBytes=152

파티션에 EC 개인키·공개키 주입

실행 위치 — HSM 클라이언트 서버

$BYOK unwrap-key-aes-kwp \
  --slot "$SLOT" --pin "$CO_PW" \
  --unwrapping-label "kek-$TS" \
  --label "ec-prv-$TS" \
  --key-type EC \
  --in "/tmp/sample-ec-$TS.kwp.b64" \
  --sign true

# 준비한 키 사용 시: --public-in 을 보유한 EC 공개키 PEM 파일로
$BYOK import-public \
  --slot "$SLOT" --pin "$CO_PW" \
  --key-type EC \
  --label "ec-pub-$TS" \
  --public-in "/tmp/sample-ec-$TS.pub.pem"
Imported EC private key label=ec-prv-20260806103000
Imported EC public key label=ec-pub-20260806103000 handle={handle}

서명·검증 왕복 테스트

실행 위치 — HSM 클라이언트 서버

$BYOK sign \
  --slot "$SLOT" --pin "$CO_PW" \
  --label "ec-prv-$TS" \
  --key-type EC \
  --text "byok signature test" \
  --out "/tmp/ec-sig-$TS.b64"

# 준비한 키 사용 시: --public-in 을 보유한 EC 공개키 PEM 파일로
$BYOK verify \
  --slot "$SLOT" --pin "$CO_PW" \
  --key-type EC \
  --public-in "/tmp/sample-ec-$TS.pub.pem" \
  --text "byok signature test" \
  --in "/tmp/ec-sig-$TS.b64"

ECDSA 서명은 DER로 인코딩된 ASN.1 구조여서 서명값에 따라 길이가 달라집니다. P-256 키의 경우 70~72바이트가 나옵니다.

signatureBytes=72
Output file: /tmp/ec-sig-20260806103000.b64
verified=true

주입한 키 조회

주입한 키가 실제로 영구(token) 객체로 기록됐는지, 의도한 용도 속성이 붙었는지 확인합니다.

실행 위치 — HSM 클라이언트 서버

$BYOK show --slot "$SLOT" --pin "$CO_PW" --label "aes-$TS"
$BYOK show --slot "$SLOT" --pin "$CO_PW" --label "rsa-prv-$TS"
$BYOK show --slot "$SLOT" --pin "$CO_PW" --label "ec-prv-$TS"

대칭키의 출력 예시입니다.

found=true label=aes-20260806103000 persistent=true
  CKA_TOKEN=true
  CKA_PRIVATE=true
  CKA_SENSITIVE=true
  CKA_EXTRACTABLE=false
  CKA_ENCRYPT=true
  CKA_DECRYPT=true
  CKA_SIGN=false
  CKA_VERIFY=false
  CKA_WRAP=false
  CKA_UNWRAP=false

:::(info) (참고)
LunaProvider의 unwrap 기본 템플릿은 AES 키에 CKA_SIGN/CKA_VERIFY(HMAC 용도)와 CKA_WRAP을, 개인키에 CKA_UNWRAP을 켜 둡니다. HsmByok의 주입 명령은 이 속성들을 끄므로 위와 같이 false로 표시됩니다. HMAC 등 다른 용도가 필요하다면 코드의 해당 setBool 줄을 수정하십시오.

:::

공개키는 label로 조회되지 않습니다. cmu list로 handle을 다시 조회한 뒤 바로 사용하십시오. 주입 시 출력된 handle은 그 프로세스 안에서만 유효합니다.

/usr/safenet/lunaclient/bin/cmu list -slot="$SLOT" -password="$CO_PW" -display=handle,label </dev/null | grep -- "-pub-$TS"
$BYOK show --slot "$SLOT" --pin "$CO_PW" --handle <위에서 조회한 handle>

:::(info) (참고)
파티션 안의 모든 객체를 handle과 함께 나열하려면 LunaClient CLI를 사용하십시오. show는 대상 하나를 확인하는 용도입니다.

/usr/safenet/lunaclient/bin/cmu list -slot="$SLOT" -password="$CO_PW" -display=handle,label </dev/null

lunacm에서는 partition contents 명령으로 확인할 수 있습니다.

:::

정리

개인키 주입에 사용한 임시 KEK는 주입이 끝나면 더 이상 필요하지 않습니다. 삭제하십시오.

실행 위치 — HSM 클라이언트 서버

$BYOK destroy --slot "$SLOT" --pin "$CO_PW" --label "kek-$TS"
Destroyed handle={handle}

wrapping 키쌍(byok-wrap-$TS-pub / -prv)은 이후 추가 주입에 재사용할 수 있으므로 남겨두어도 됩니다.

키 보유 측의 평문 키 파일과 중간 산출물도 삭제하십시오.

보유하신 키 파일의 경로를 직접 지정해 진행했다면, 아래 rm의 경로를 그 파일로 바꾸지 마십시오. 원본 키 파일이 삭제됩니다. 아래 명령은 이 문서에서 만든 샘플 키·평문 사본과 중간 산출물만 지웁니다.

실행 위치 — 키 보유 측 작업 (이 예제에서는 같은 서버에서 실행)

rm -f "/tmp/sample-rsa-$TS.pem" "/tmp/sample-rsa-$TS.pk8.der" \
      "/tmp/sample-ec-$TS.pem" "/tmp/sample-ec-$TS.pk8.der" \
      "/tmp/sample-aes-$TS.bin" \
      "/tmp/kek-$TS.bin" "/tmp/kek-$TS.oaep.bin" "/tmp/kek-$TS.oaep.b64" \
      "/tmp/sample-aes-$TS.oaep.bin" "/tmp/sample-aes-$TS.oaep.b64" \
      "/tmp/sample-rsa-$TS.kwp.b64" "/tmp/sample-ec-$TS.kwp.b64" \
      "/tmp/rsa-sig-$TS.b64" "/tmp/ec-sig-$TS.b64" "/tmp/oaep-ct-$TS.b64"

일부 키 유형만 주입했다면 해당하지 않는 파일과 명령은 건너뛰십시오. rm -f는 없는 파일이 있어도 오류 없이 넘어갑니다.

이 문서의 절차를 시험 삼아 실행하신 경우, 주입한 키도 정리하실 수 있습니다.

$BYOK destroy --slot "$SLOT" --pin "$CO_PW" --label "aes-$TS"       # AES 키를 주입한 경우
$BYOK destroy --slot "$SLOT" --pin "$CO_PW" --label "rsa-prv-$TS"   # RSA 개인키를 주입한 경우
$BYOK destroy --slot "$SLOT" --pin "$CO_PW" --label "ec-prv-$TS"    # EC 개인키를 주입한 경우

:::(info) (참고)
공개키는 LunaProvider에서 label로 조회되지 않아 destroy --label로 삭제할 수 없습니다. cmu delete에 label을 지정해 삭제하십시오.

/usr/safenet/lunaclient/bin/cmu delete -slot="$SLOT" -password="$CO_PW" -publiclabel="rsa-pub-$TS" -force </dev/null   # RSA 공개키를 주입한 경우
/usr/safenet/lunaclient/bin/cmu delete -slot="$SLOT" -password="$CO_PW" -publiclabel="ec-pub-$TS" -force </dev/null    # EC 공개키를 주입한 경우

:::

모든 작업이 끝나면 비밀번호 변수를 지우십시오.

unset CO_PW

문제 해결

PIN is incorrect 또는 Partition login failed: slot=...

SLOT/CO_PW를 실제 값으로 바꾸지 않았거나, 슬롯 번호나 비밀번호가 틀린 경우 발생합니다. 비밀번호가 틀리면 LunaException: PIN is incorrect(C_Login 반환값 0xa0)가 출력됩니다. lunacm:> slot list로 슬롯 번호를, Crypto Officer 비밀번호를 다시 확인하십시오. Crypto User(CU) 비밀번호를 입력해도 같은 오류가 납니다. 로그인 실패가 파티션 정책 20(Max failed user logins allowed)의 횟수만큼 이어지면 역할이 잠길 수 있으므로, 비밀번호를 확인한 뒤 다시 시도하십시오.

ClassNotFoundException: com.safenetinc.luna.provider.LunaProvider 또는 UnsatisfiedLinkError

LunaClient가 설치되어 있지 않거나, javac/java 명령의 -cp/-Djava.library.path 경로가 실제 설치 경로(/usr/safenet/lunaclient/jsp/lib/)와 다른 경우 발생합니다.

unwrap 단계에서 CKR_ENCRYPTED_DATA_INVALID 또는 CKR_WRAPPED_KEY_INVALID

wrapped blob을 만들 때 사용한 파라미터가 HSM 쪽과 다른 경우입니다. 다음을 확인하십시오.

  • OpenSSL 명령에 -pkeyopt rsa_oaep_md:sha256과 -pkeyopt rsa_mgf1_md:sha256이 모두 들어갔는지 (OpenSSL 기본값은 SHA-1입니다)
  • 감쌀 때 사용한 공개키 PEM이 --unwrapping-label로 지정한 개인키와 같은 키쌍인지
  • base64 파일이 전송 과정에서 잘리지 않았는지

주입은 성공했는데 서명에서 CKR_KEY_FUNCTION_NOT_PERMITTED

파티션 정책 17(Allow signing with non-local keys)이 꺼져 있으면 파티션 밖에서 만들어진 키로 서명할 수 없습니다. lunacm:> partition showPolicies -verbose로 확인하십시오. 이 정책은 켜는 방향이 비파괴적이므로 기존 키를 잃지 않고 켤 수 있습니다. 확인·변경 방법은 파티션 정책 요구사항을 참고하십시오.

주입은 성공했는데 복호화에서 CKR_KEY_FUNCTION_NOT_PERMITTED

주입할 때 --decrypt true 를 지정했는지 확인하십시오. show --label <label> 로 CKA_DECRYPT 값을 직접 볼 수 있습니다.

Key already exists: label=...

같은 label의 키가 파티션에 이미 있는 경우입니다. 같은 TS로 절차를 다시 실행하면 이 오류가 납니다. 앞서 만든 키가 더 필요 없다면 destroy --label로 삭제한 뒤 다시 실행하고, 남겨 둘 키라면 TS를 새로 정해(export TS=$(date +%Y%m%d%H%M%S)) 처음부터 진행하십시오. 새 TS로 진행하면 wrapping 키쌍과 감싼 파일도 새로 만들어야 합니다.

Failed to set attribute=...

주입한 키에 용도 속성을 지정하지 못한 경우입니다. 키 객체는 이미 만들어졌으므로, show --label로 상태를 확인한 뒤 destroy --label로 삭제하십시오. 공개키는 label로 조회되지 않으므로 cmu delete -publiclabel=<label>로 삭제하십시오. 파티션 정책 11(Allow changing key attributes)이 꺼져 있으면 이 오류가 납니다. 파티션 정책 요구사항을 참고하십시오.

컴파일 시 error: unmappable character (0xED) for encoding US-ASCII

javac에 -encoding UTF-8을 지정하지 않은 경우입니다. 서버 로케일이 C/POSIX이면 javac의 기본 소스 인코딩이 US-ASCII가 되어 소스의 한글 주석을 읽지 못합니다. 컴파일 명령대로 -encoding UTF-8을 넣어 다시 컴파일하십시오.

파일이 UTF-8로 저장됐는지도 함께 확인하십시오.

file HsmByok.java
# HsmByok.java: Unicode text, UTF-8 text  <- 이렇게 나와야 합니다

공개키를 주입했는데 show --label이 found=false

정상입니다. LunaProvider의 label 기반 탐색 API는 공개키 객체를 검색하지 않습니다. cmu list처럼 PKCS#11로 직접 조회하면 공개키도 label과 함께 표시됩니다. cmu list로 handle을 다시 조회한 뒤 바로 사용하십시오. 주입 시 출력된 handle은 그 프로세스 안에서만 유효하므로 나중에 쓰면 0x82(CKR_OBJECT_HANDLE_INVALID)로 실패하거나 다른 객체를 가리킬 수 있습니다.

/usr/safenet/lunaclient/bin/cmu list -slot="$SLOT" -password="$CO_PW" -display=handle,label </dev/null | grep -- "-pub-$TS"
$BYOK show --slot "$SLOT" --pin "$CO_PW" --handle <위에서 조회한 handle>

파티션 안의 공개키를 handle과 함께 확인하려면 LunaClient CLI를 사용하십시오.

/usr/safenet/lunaclient/bin/cmu list -slot="$SLOT" -password="$CO_PW" -display=handle,label </dev/null

안내 문구의 한글이 ??로 표시됨

$BYOK help의 (RSA ??)처럼 한글이 ??로 보이는 경우입니다. 서버에 설정된 로케일(예: LANG=ko_KR.UTF-8)이 설치되어 있지 않으면 Java가 출력 인코딩을 ASCII로 정해 한글을 표시하지 못합니다. 명령 동작에는 영향이 없습니다. 아래와 같이 UTF-8 로케일을 지정한 뒤 다시 실행하십시오.

export LANG=C.UTF-8

Cipher.getInstance("AES/KWP/NoPadding")에서 NoSuchAlgorithmException

키 보유 측 헬퍼(KwpWrap)를 JDK 16 이하에서 실행한 경우입니다. AES/KWP/NoPadding은 JDK 17부터 표준 제공됩니다. JDK 17 이상에서 실행하십시오.

운영 적용 시 조정 항목

이 문서의 예제는 절차 확인을 위한 최소 구성입니다. 운영 환경에 적용할 때는 다음을 함께 고려하십시오.

  • label 대신 CKA_ID 기준 관리 — label은 중복이 허용되므로, 테넌트/키 ID 기반의 CKA_ID를 함께 지정해 중복 주입과 재시도를 제어하십시오.
  • 키 메타데이터 별도 보관 — 키 유형, mechanism, 해시/MGF, 용도, KCV(키 검증값), 만료 시각, 승인 번호를 HSM 바깥에 함께 기록하십시오. HSM 객체만으로는 어떤 키를 언제 어떤 조건으로 주입했는지 추적할 수 없습니다.
  • 주입 전 blob 검증 — 전달받은 wrapped blob의 포맷, 크기, 키 유형과 label/CKA_ID 중복 여부를 unwrap 전에 검증하십시오.
  • 비밀번호 전달 방식 — 이 문서의 예제는 CO 비밀번호를 셸에서 입력받지만, HsmByok에는 명령행 인자(--pin)로 전달합니다. 명령행 인자는 실행 중 프로세스 목록에 노출되므로, 운영 코드에서는 환경 변수나 별도의 비밀 관리 체계에서 직접 읽으십시오.
  • 용도 속성 확인 — LunaProvider의 unwrap 기본 템플릿은 개인키에 CKA_UNWRAP을, 대칭키에 CKA_SIGN/CKA_VERIFY/CKA_WRAP을 켜 둡니다. HsmByok은 이 속성들을 끄도록 작성했습니다. 운영 코드를 따로 작성한다면 같은 속성을 명시적으로 지정하고, show로 결과를 확인하십시오.
  • 평문 키 파일 정리 — 키 보유 측에서 사용한 평문 키와 임시 KEK 파일은 작업 직후 삭제하십시오.

다음 단계