공개 키 암호화

공개 키(비대칭) 암호화는 공유 비밀 대신 키 쌍을 사용합니다: 개인 키가 서명한 것은 무엇이든 그에 대응하는 공개 키로 검증할 수 있고, 공개 키로 봉인된 것은 무엇이든 그에 대응하는 개인 키만이 열 수 있습니다. 공개 키는 공격자를 포함한 누구에게나 넘겨줘도 아무것도 약화되지 않습니다.

 

RSA

 

키 생성은 큰 소수를 찾는 과정으로 실제로 느립니다. 2048비트 키의 경우 몇 초가 걸리므로 절대 UI 스레드에서 실행하지 마십시오. 암호화에는 OAEP를, 서명에는 PSS를 사용하십시오. 둘 다 PKCS#1 v1.5에는 없는 무작위 요소를 포함합니다. PKCS#1 v1.5 서명/검증은 이를 명시하는 형식(가장 흔한 경우는 JWS RS256)과의 상호 운용을 위해 sgcCrypto_RSA에 제공됩니다. 새로운 용도로는 PSS를 선호하십시오.

 


function sgcRSA_GenerateKey(aBits: Integer): TsgcRSAPrivateKey;
function sgcRSA_PublicKey(const aPrivateKey: TsgcRSAPrivateKey): TsgcRSAPublicKey;
function sgcRSA_OAEP_Encrypt(const aKey: TsgcRSAPublicKey; const aPlain, aLabel: TBytes; aHash: TsgcRSAHash): TBytes;
function sgcRSA_OAEP_Decrypt(const aKey: TsgcRSAPrivateKey; const aCipher, aLabel: TBytes; aHash: TsgcRSAHash; out aPlain: TBytes): Boolean;
function sgcRSA_PSS_Sign(const aKey: TsgcRSAPrivateKey; const aData: TBytes; aHash: TsgcRSAHash; aSaltLen: Integer = -1): TBytes;
function sgcRSA_PSS_Verify(const aKey: TsgcRSAPublicKey; const aData, aSignature: TBytes; aHash: TsgcRSAHash; aSaltLen: Integer = -1): Boolean;

var
  oKey: TsgcRSAPrivateKey;
  oSignature: TBytes;
begin
  oKey := sgcRSA_GenerateKey(2048);
  oSignature := sgcRSA_PSS_Sign(oKey, oData, rhSHA256);
  if sgcRSA_PSS_Verify(sgcRSA_PublicKey(oKey), oData, oSignature, rhSHA256) then
    { valid };
end;

 

키는 PKCS#1 및 SubjectPublicKeyInfo DER로, 그리고 PEM으로 내보낼 수 있습니다:

 


function sgcRSA_ExportPrivateKeyPEM(const aKey: TsgcRSAPrivateKey): string;
function sgcRSA_ExportPublicKeyPEM(const aKey: TsgcRSAPublicKey): string;

 

PKCS#8은 또 다른 개인 키 컨테이너로, BEGIN PRIVATE KEY로 기록되며 Java와 .NET이 기대하는 형식입니다. 위의 함수 쌍은 대신 더 오래된 PKCS#1 형식인 BEGIN RSA PRIVATE KEY를 내보냅니다. 어느 컨테이너도 암호화되어 있지 않으므로, 결과물은 있는 그대로의 키 자료이며 로그나 백업이 닿을 수 있는 곳에는 있어서는 안 됩니다.

 


function sgcRSA_ExportPrivateKeyPKCS8DER(const aKey: TsgcRSAPrivateKey): TBytes;
function sgcRSA_ExportPrivateKeyPKCS8PEM(const aKey: TsgcRSAPrivateKey): string;

 

RSA 키 가져오기

 

이 함수들이 존재하기 전까지 TsgcRSAPrivateKey는 오직 sgcRSA_GenerateKey에서만 나올 수 있었으며, 이는 디스크에 저장된 키를 전혀 사용할 수 없다는 뜻이었습니다. 가져오기 루틴은 두 가지 개인 키 컨테이너, 즉 PKCS#1 RSAPrivateKey와 PKCS#8 PrivateKeyInfo를 읽으며, 두 가지 공개 키 인코딩, 즉 PKCS#1 RSAPublicKey와 X.509 SubjectPublicKeyInfo를 DER 또는 PEM 형태로 읽습니다. 암호화된 PKCS#8, 즉 BEGIN ENCRYPTED PRIVATE KEY 형식은 지원되지 않으며 False를 반환합니다. CRT 매개변수는 파일에 없을 경우 다시 계산되므로, 가져온 키도 전체 속도로 동작합니다.

 


function sgcRSA_ImportPrivateKeyDER(const aDER: TBytes; out aKey: TsgcRSAPrivateKey): Boolean;
function sgcRSA_ImportPrivateKeyPEM(const aPEM: string; out aKey: TsgcRSAPrivateKey): Boolean;
function sgcRSA_ImportPublicKeyDER(const aDER: TBytes; out aKey: TsgcRSAPublicKey): Boolean;
function sgcRSA_ImportPublicKeyPEM(const aPEM: string; out aKey: TsgcRSAPublicKey): Boolean;

var
  oKey: TsgcRSAPrivateKey;
  oSignature: TBytes;
begin
  if sgcRSA_ImportPrivateKeyPEM(Memo1.Text, oKey) then
    oSignature := sgcRSA_PSS_Sign(oKey, oData, rhSHA256);
end;

 

인증서는 공개 키가 아니므로, sgcRSA_ImportPublicKeyDER에는 인증서 자체가 아니라 인증서에서 추출한 SubjectPublicKeyInfo를 건네주십시오. sgcRSA_CheckPrivateKey는 다른 곳에서 온 값의 내부 일관성을 검증합니다: n = p*q인지, e*d가 1과 합동인지, 그리고 CRT 매개변수가 일치하는지입니다.

 

Ed25519, Ed448 (서명)

 

EdDSA의 논스는 개인 키와 메시지로부터 결정적으로 유도되므로, 서명 과정에 난수 생성기가 전혀 관여하지 않고, ECDSA(아래 참조)와 달리 논스가 반복되어 키가 유출될 가능성도 없습니다. sgcCrypto_Ed25519는 sgcWebSockets의 다른 곳에서 WebAuthn EdDSA 자격 증명 유형이 사용하는 검증/서명 기본 요소입니다. 호출자가 이미 32바이트 공개 키를 알고 있다고 가정하며(WebAuthn은 자격 증명에서 이를 직접 건네줍니다), 별도의 키 생성이나 공개 키 유도 호출을 제공하지 않습니다. sgcCrypto_Ed448은 범용 서명자로, Ed25519의 128비트에 비해 대략 224비트의 보안을 제공하는 대신 키와 서명이 더 크고 서명 속도가 더 느립니다.

 


{ Ed25519: caller supplies the 32-byte seed and, to verify, the 32-byte public key. }
function sgcEd25519_Sign(const aPrivateSeed, aMessage: TBytes): TBytes;
function sgcEd25519_Verify(const aPublicKey, aMessage, aSignature: TBytes): Boolean;

{ Ed448: 57-byte keys, 114-byte signatures, optional context string. }
procedure sgcEd448_GenerateKeyPair(out aPrivateKey, aPublicKey: TBytes);
function sgcEd448_Sign(const aPrivateKey, aMessage, aContext: TBytes): TBytes;
function sgcEd448_Verify(const aPublicKey, aMessage, aSignature, aContext: TBytes): Boolean;

 

X25519, X448 (키 합의)

 

Montgomery 곡선 위에서의 Diffie-Hellman입니다: 양쪽이 각각 키 쌍을 생성해 공개 키를 교환하고, 각자 자신의 개인 키와 상대방의 공개 키로 같은 공유 비밀을 계산합니다. 무엇을 암호화하든 사용하기 전에 원시 출력을 반드시 KDF(비밀번호 해싱 및 키 유도의 HKDF 참조)에 통과시키십시오. 원시 ECDH 출력 자체는 적합한 키가 아닙니다. X448은 X25519의 128비트에 비해 대략 224비트의 보안을 제공하는 대신 키가 더 크고 연산량이 약 세 배입니다.

 


function sgcX25519_PublicKey(const aPrivateKey: TBytes): TBytes;
function sgcX25519_SharedSecret(const aPrivateKey, aPeerPublicKey: TBytes; out aSecret: TBytes): Boolean;
function sgcX448_PublicKey(const aPrivateKey: TBytes): TBytes;
function sgcX448_SharedSecret(const aPrivateKey, aPeerPublicKey: TBytes; out aSecret: TBytes): Boolean;

var
  oAlicePriv, oBobPriv, oAlicePub, oBobPub, oSecretA, oSecretB: TBytes;
begin
  oAlicePriv := sgcRandomBytes(32);
  oBobPriv := sgcRandomBytes(32);
  oAlicePub := sgcX25519_PublicKey(oAlicePriv);
  oBobPub := sgcX25519_PublicKey(oBobPriv);
  sgcX25519_SharedSecret(oAlicePriv, oBobPub, oSecretA);
  sgcX25519_SharedSecret(oBobPriv, oAlicePub, oSecretB);
  { oSecretA = oSecretB }
end;

 

SharedSecret은 로우 오더 상대 공개 키, 즉 자신의 개인 키와 무관하게 공유 비밀을 전부 0으로 만드는 작은 공개 키 집합에 대해서는 False를 반환합니다. 결과를 무조건 신뢰하지 말고 항상 확인하십시오.

 

ECIES (실드 박스 암호화)

 

X25519, HKDF-SHA256, XChaCha20-Poly1305를 하나의 호출로 결합해, 사전에 공유 비밀 없이도 수신자의 공개 키로 메시지를 암호화합니다. aContext는 봉인된 메시지를 목적이나 프로토콜 단계에 결속시키며, 다른 컨텍스트로 열려고 하면 실패합니다. 이는 한 교환에서 봉인된 메시지가 다른 교환으로 재생되는 것을 막습니다.

 


procedure sgcECIES_GenerateKeyPair(out aPrivateKey, aPublicKey: TBytes);
function sgcECIES_Seal(const aRecipientPublicKey, aPlain, aAAD, aContext: TBytes): TBytes;
function sgcECIES_Open(const aPrivateKey, aSealed, aAAD, aContext: TBytes; out aPlain: TBytes): Boolean;

 

명명된 곡선에서의 ECDSA / ECDH

 

두 개의 별도 유닛이 짧은 Weierstrass 곡선을 다루며, 어떤 곡선 계열을 구현하는지에 따라 나뉩니다:

 

sgcCrypto_EC는 NIST 곡선(P-256, P-384, P-521)을 다루며, PEM 키와 JWS 서명, 즉 JWT 라이브러리가 기대하는 형태(ES256 등)를 중심으로 구성되어 있습니다.

 

sgcCrypto_ECCurves는 secp256k1(Bitcoin과 Ethereum의 곡선)과 RFC 5639의 brainpoolP256r1 / P384r1 / P512r1 곡선을 다루며, PEM이 아니라 원시 키와 서명 바이트를 직접 다룹니다.

 

sgcCrypto_ECCurves의 TsgcECCurve는 이제 네 개가 아니라 일곱 개의 곡선을 명명합니다: eccSecp256k1, 세 가지 Brainpool 곡선, 그리고 NIST P-256, P-384, P-521을 위한 eccP256, eccP384, eccP521입니다. NIST 세 곡선은 예전에는 sgcCrypto_EC의 PEM 및 JWS API를 통해서만 접근할 수 있었으므로, P-256 키를 원시 바이트로 갖고 있는 애플리케이션은 먼저 PEM을 작성해야 했습니다. 아래의 원시 키 생성, 서명, 검증, ECDH, 압축 호출은 이제 일곱 개 모두를 다룹니다. 작업이 JWS라면 sgcCrypto_EC를, 키 자료가 이미 바이트 형태라면 sgcCrypto_ECCurves를 사용하십시오.

 

두 유닛 모두 TsgcECCurve라는 타입과 sgcEC_GenerateKeyPair라는 루틴을 선언하지만, 유닛마다 의미가 다릅니다. 두 유닛을 같은 uses 절에 함께 추가하고 이름을 한정하지 않은 채 참조하지 마십시오. 하나의 유닛에서 정말로 둘 다 필요하다면 유닛 이름으로 한정하십시오(sgcCrypto_ECCurves.sgcEC_GenerateKeyPair).

 


{ sgcCrypto_ECCurves: secp256k1 and Brainpool, RFC 6979 deterministic nonces,
  low-S normalized signatures (BIP 62). }
procedure sgcEC_GenerateKeyPair(aCurve: TsgcECCurve; out aPrivateKey, aPublicKey: TBytes);
function sgcECDSA_SignHash(aCurve: TsgcECCurve; const aPrivateKey, aHash: TBytes; aHashKind: TsgcECHash = echSHA256): TBytes;
function sgcECDSA_VerifyHash(aCurve: TsgcECCurve; const aPublicKey, aHash, aSignature: TBytes): Boolean;
function sgcECDH_SharedSecret(aCurve: TsgcECCurve; const aPrivateKey, aPeerPublicKey: TBytes; out aSecret: TBytes): Boolean;
function sgcEC_ValidatePublicKey(aCurve: TsgcECCurve; const aPublicKey: TBytes): Boolean;
function sgcEC_Compress(aCurve: TsgcECCurve; const aPublicKey: TBytes): TBytes;
function sgcEC_Decompress(aCurve: TsgcECCurve; const aCompressed: TBytes): TBytes;

 

여기서 ECDSA 서명은 RFC 6979 결정적 논스를 사용합니다: 논스가 난수 생성기에서 뽑히는 대신 개인 키와 메시지로부터 유도되며, 이는 실제 배포 환경에서 ECDSA 개인 키를 잃어버리는 가장 흔한 원인, 즉 반복되거나 부분적으로 예측 가능한 논스를 제거합니다. 서명에는 Bitcoin(BIP 62)과 Ethereum이 요구하는 low-S 정규화가 적용되며, 검증은 두 형태 모두를 받아들입니다.

 

DER 인코딩된 ECDSA 서명

 

서명은 하나지만 인코딩은 두 가지이며, 잘못된 쪽을 고르는 것은 이 영역에서 가장 흔한 통합 버그 중 하나입니다. 곡선 필드 크기의 절반씩을 차지하는 원시 쌍 R || S는 JOSE와 WebAuthn이 사용하는 형태입니다. DER SEQUENCE { r, s }는 X.509, CMS, TLS가 사용하는 형태입니다. 위의 sgcECDSA_SignHash는 원시 형태를 생성하며, 아래의 호출들은 DER 형태를 생성하고 소비하고, 두 변환 함수는 다시 서명하지 않고도 둘 사이를 변환합니다.

 


{ sgcCrypto_ECCurves: raw keys, a digest the caller has already computed. }
function sgcECDSA_SignDER(aCurve: TsgcECCurve; const aPrivateKey, aHash: TBytes; aHashKind: TsgcECHash): TBytes; overload;
function sgcECDSA_VerifyDER(aCurve: TsgcECCurve; const aPublicKey, aHash, aDERSignature: TBytes): Boolean; overload;
function sgcECDSA_RawToDER(aCurve: TsgcECCurve; const aRaw: TBytes): TBytes;
function sgcECDSA_DERToRaw(aCurve: TsgcECCurve; const aDER: TBytes; out aRaw: TBytes): Boolean;

{ sgcCrypto_EC: PEM keys, hashes aData itself with the curve SHA. }
function sgcECDSA_SignDER(const aPrivateKeyPEM: string; const aData: TBytes; aCurveBits: Integer): TBytes; overload;
function sgcECDSA_VerifyDER(const aPublicPoint, aData, aDERSignature: TBytes; aCurveBits: Integer): Boolean; overload;

var
  oRaw, oDER: TBytes;
begin
  oRaw := sgcECDSA_SignHash(eccP256, oPrivateKey, oDigest, echSHA256);
  oDER := sgcECDSA_RawToDER(eccP256, oRaw);
end;

 

aHashKind 는 원시 키 형태에서 RFC 6979 논스 뒤에 있는 HMAC만을 선택할 뿐, 여러분을 위해 무언가를 해시하지는 않으므로, 달리 선택할 이유가 없다면 sgcEC_DefaultHash(aCurve)를 전달하십시오. sgcECDSA_RawToDER는 그 쌍이 정확히 필드 크기의 절반 두 개가 아니면 빈 배열을 반환하며, sgcECDSA_DERToRaw는 잘못된 DER이거나 곡선에 맞지 않는 값에 대해 False를 반환하므로, 둘 다 신뢰할 수 없는 입력을 대상으로 사용해도 안전합니다.

 

EC 키 내보내기와 가져오기

 

세 가지 컨테이너가 있으며, 이를 구분하는 것은 PEM 레이블입니다: SEC 1 ECPrivateKey는 BEGIN EC PRIVATE KEY이고, PKCS#8 PrivateKeyInfo는 BEGIN PRIVATE KEY이며, X.509 SubjectPublicKeyInfo는 BEGIN PUBLIC KEY입니다. Java와 .NET은 PKCS#8을 원하며, OpenSSL과 대부분의 명령줄 도구는 둘 다 읽습니다. 두 개인 키 형태 모두 암호화되어 있지 않으므로, 결과물은 스칼라 값 자체만큼이나 비밀입니다.

 


function sgcEC_ExportPrivateKeySEC1DER(aCurve: TsgcECCurve; const aPrivateKey, aPublicKey: TBytes): TBytes;
function sgcEC_ExportPrivateKeyPKCS8DER(aCurve: TsgcECCurve; const aPrivateKey, aPublicKey: TBytes): TBytes;
function sgcEC_ExportSubjectPublicKeyInfo(aCurve: TsgcECCurve; const aPublicKey: TBytes): TBytes;
function sgcEC_ExportPrivateKeySEC1PEM(aCurve: TsgcECCurve; const aPrivateKey, aPublicKey: TBytes): string;
function sgcEC_ExportPrivateKeyPKCS8PEM(aCurve: TsgcECCurve; const aPrivateKey, aPublicKey: TBytes): string;
function sgcEC_ExportPublicKeyPEM(aCurve: TsgcECCurve; const aPublicKey: TBytes): string;

function sgcEC_ImportPrivateKeyDER(const aDER: TBytes; out aCurve: TsgcECCurve; out aPrivateKey, aPublicKey: TBytes): Boolean;
function sgcEC_ImportPrivateKeyPEM(const aPEM: string; out aCurve: TsgcECCurve; out aPrivateKey, aPublicKey: TBytes): Boolean;
function sgcEC_ImportPublicKeyDER(const aDER: TBytes; out aCurve: TsgcECCurve; out aPublicKey: TBytes): Boolean;
function sgcEC_ImportPublicKeyPEM(const aPEM: string; out aCurve: TsgcECCurve; out aPublicKey: TBytes): Boolean;

var
  oCurve: TsgcECCurve;
  oPrivateKey, oPublicKey: TBytes;
begin
  if sgcEC_ImportPrivateKeyPEM(Memo1.Text, oCurve, oPrivateKey, oPublicKey) then
    Memo2.Text := sgcEC_ExportPrivateKeyPKCS8PEM(oCurve, oPrivateKey, oPublicKey);
end;

 

가져오기 루틴은 곡선을 파일에서 읽어오며, 이것이 aCurve가 전달되는 대신 out 매개변수로 반환되는 이유입니다. 유닛이 구현하지 않은 곡선은 버그가 아니라 협상 실패이므로, 이 경우 예외를 일으키지 않고 False를 반환하며, 이는 신뢰할 수 없는 입력에 대해서도 안전하게 만듭니다. 공개 점은 파일에 빠져 있을 경우 스칼라 값으로부터 다시 계산되며, 반환되기 전에 곡선에 대해 검증됩니다. 내보내기 루틴은 대신 예외를 일으키는데, 곡선과 맞지 않는 키 자료는 입력이 아니라 호출자 측의 버그이기 때문입니다.

 

이러한 각 컨테이너에 들어가는 점으로 구분된(dotted) 명명된 곡선 OID는 직접 사용할 수 있으며, 내보내기와 가져오기 루틴이 내부적으로 사용하는 것과 동일합니다:

 


function sgcEC_CurveOID(aCurve: TsgcECCurve): string;
function sgcEC_CurveFromOID(const aDotted: string; out aCurve: TsgcECCurve): Boolean;

 

상대방의 공개 키는 항상 검증하십시오

 

직접 생성하지 않은 공개 키를 사용하기 전에는, ECDH든 ECDSA 검증이든 ECIES든 sgcEC_ValidatePublicKey로 그것이 실제로 곡선 위의 점인지 검증하십시오. 곡선 위에 있지 않은 점은 이를 처리하는 데 사용되는 연산을 통해 자신의 개인 키 일부를 유출시킬 수 있습니다.

 

Schnorr 서명 (BIP-340)

 

secp256k1 전용이며, 이는 Taproot, Nostr, Lightning이 사용하는 방식입니다. 위의 ECDSA를 그대로 대체할 수 있는 것은 아닌데, 두 가지가 다르기 때문입니다. 공개 키는 x-only, 즉 y 좌표가 짝수라고 암묵적으로 정해진 32바이트이므로, BIP-340 키는 이 페이지의 나머지 부분이 만들어내는 33바이트 압축 형태나 65바이트 비압축 형태와 서로 바꿔 쓸 수 없습니다. 그리고 챌린지는 공개 키를 논스 및 메시지와 함께 해시하므로, 이는 ECDSA 서명에는 없는 방식으로 서명을 키에 결속시킵니다.

 


function sgcSchnorr_PublicKey(const aPrivateKey: TBytes): TBytes;
function sgcSchnorr_Sign(const aPrivateKey, aMessage, aAuxRand: TBytes): TBytes;
function sgcSchnorr_Verify(const aPublicKey, aMessage, aSignature: TBytes): Boolean;
function sgcSchnorr_TaggedHash(const aTag: string; const aData: TBytes): TBytes;

var
  oPrivateKey, oPublicKey, oSignature: TBytes;
begin
  oPrivateKey := sgcRandomBytes(32);
  oPublicKey := sgcSchnorr_PublicKey(oPrivateKey);   { 32 bytes, x-only }
  oSignature := sgcSchnorr_Sign(oPrivateKey, oMessage, sgcRandomBytes(32));
  if sgcSchnorr_Verify(oPublicKey, oMessage, oSignature) then
    { valid };
end;

 

aAuxRand는 논스 유도에 섞이는 32바이트의 보조 무작위성입니다. 이는 결함 주입 공격(fault attack)에 대한 심층 방어일 뿐 논스 그 자체는 아닙니다: 이 값이 전부 0이어도 방식 자체는 안전하게 유지되며, 빈 배열은 32바이트의 0으로 취급되어 BIP-340 테스트 벡터가 정확히 재현됩니다. 실제 사용에서는 새로운 무작위 값을 전달해야 합니다. sgcSchnorr_TaggedHash는 BIP-341과 BIP-342가 같은 구성 위에 추가 태그를 쌓아 올리기 때문에 노출되어 있습니다.