Public Key Cryptography

Public key (asymmetric) cryptography uses a key pair instead of a shared secret: whatever the private key signs, the matching public key verifies; whatever is sealed to a public key, only the matching private key opens. The public key can be handed to anyone, including an adversary, without weakening anything.

RSA

Key generation searches for large primes and is genuinely slow, several seconds for a 2048 bit key; never run it on a UI thread. Use OAEP for encryption and PSS for signatures, both include a randomized element that PKCS#1 v1.5 lacks. PKCS#1 v1.5 sign/verify is provided in sgcCrypto_RSA for interoperating with formats that specify it (JWS RS256 is the common case); prefer PSS for anything new.


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 v1.5 encryption is provided as well, the mode Java names RSA/NONE/PKCS1Padding. It is here for interoperating with formats that mandate it and for nothing else: v1.5 padding is exactly what the Bleichenbacher attack targets, so OAEP stays the right choice for anything new. The plaintext is limited to the modulus size minus 11 bytes, 245 bytes with a 2048 bit key.


function sgcRSA_PKCS1_Encrypt(const aKey: TsgcRSAPublicKey; const aPlain: TBytes): TBytes;
function sgcRSA_PKCS1_Decrypt(const aKey: TsgcRSAPrivateKey; const aCipher: TBytes; out aPlain: TBytes): Boolean;

sgcRSA_PKCS1_Decrypt returns False for every kind of decoding failure and takes the same path through each of them, because telling them apart is precisely what a padding oracle needs.

OAEP with a separate MGF1 hash

OAEP uses a hash in two places, over the label and inside the MGF1 mask generation function, and the two do not have to be the same. That is where Java bites. SunJCE reads OAEPWithSHA256AndMGF1Padding as SHA-256 for the digest with MGF1 still on SHA-1, while Bouncy Castle reads the same string as SHA-256 for both, so a ciphertext one of them produces is undecryptable by the other. The five parameter overloads pin the two hashes independently, which is what talking to a Java peer can require. The four parameter form is unchanged and passes aHash for both.


function sgcRSA_OAEP_Encrypt(const aKey: TsgcRSAPublicKey; const aPlain, aLabel: TBytes; aHash, aMGFHash: TsgcRSAHash): TBytes; overload;
function sgcRSA_OAEP_Decrypt(const aKey: TsgcRSAPrivateKey; const aCipher, aLabel: TBytes; aHash, aMGFHash: TsgcRSAHash; out aPlain: TBytes): Boolean; overload;

var
  oKey: TsgcRSAPrivateKey;
  oCipher: TBytes;
begin
  { SunJCE OAEPWithSHA256AndMGF1Padding: SHA-256 label hash, MGF1 on SHA-1. }
  oCipher := sgcRSA_OAEP_Encrypt(sgcRSA_PublicKey(oKey), oPlain, nil, rhSHA256, rhSHA1);
end;

A hash mismatch is indistinguishable from a corrupt ciphertext, so a wrong aMGFHash comes back as a plain False with nothing to say which of the two is wrong. Both peers have to agree on it in advance.

Keys export to PKCS#1 and SubjectPublicKeyInfo DER, and to PEM:


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

PKCS#8 is the other private key container, the one written as BEGIN PRIVATE KEY, and it is what Java and .NET expect; the pair above emits the older PKCS#1 BEGIN RSA PRIVATE KEY instead. Neither container is encrypted, so what comes back is bare key material and belongs nowhere a log or a backup can reach.


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

Importing an RSA key

Until these existed a TsgcRSAPrivateKey could only come out of sgcRSA_GenerateKey, which meant a key saved to disk could not be used at all. The import routines read both private key containers, PKCS#1 RSAPrivateKey and PKCS#8 PrivateKeyInfo, and both public key encodings, PKCS#1 RSAPublicKey and X.509 SubjectPublicKeyInfo, as DER or as PEM. An encrypted PKCS#8, the BEGIN ENCRYPTED PRIVATE KEY form, is not supported and returns False. The CRT parameters are recomputed when the file omits them, so an imported key still runs at full speed.


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;

A certificate is not a public key, so hand sgcRSA_ImportPublicKeyDER the SubjectPublicKeyInfo pulled out of the certificate, not the certificate itself. sgcRSA_CheckPrivateKey verifies the internal consistency of anything that came from elsewhere, that n = p*q, that e*d is congruent to 1, and that the CRT parameters match.

Ed25519 and Ed448 (signatures)

EdDSA nonces are derived deterministically from the private key and the message, so there is no random number generator involved in signing and no possibility of a repeated nonce leaking the key, unlike ECDSA (see below). sgcCrypto_Ed25519 is the verify/sign primitive used by the WebAuthn EdDSA credential type elsewhere in sgcWebSockets; it expects the caller to already know the 32-byte public key (WebAuthn hands it to you from the credential) and does not expose a separate key generation or public-key-derivation call. sgcCrypto_Ed448 is the general purpose signer, with roughly 224 bits of security against 128 for Ed25519, at the cost of a larger key and signature and slower signing.


{ 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 and X448 (key agreement)

Diffie-Hellman on Montgomery curves: both sides generate a key pair, exchange public keys, and each computes the same shared secret from its own private key and the other side's public key. Always run the raw output through a KDF (see Password Hashing & Key Derivation, HKDF) before using it to encrypt anything; the raw ECDH output is not itself a suitable key. X448 gives roughly 224 bits of security against 128 for X25519, at the cost of a larger key and about three times the computation.


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 returns False for a low order peer key, the small set of public keys that force the shared secret to all zeros regardless of your own private key. Always check the result rather than trusting the output unconditionally.

ECIES (sealed box encryption)

Combines X25519, HKDF-SHA256 and XChaCha20-Poly1305 into a single call that encrypts a message to a recipient's public key, with no prior shared secret needed. aContext binds the sealed message to a purpose or protocol step, opening under a different context fails, which stops a sealed message from one exchange being replayed into another.


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 on named curves

Two separate units cover the short Weierstrass curves, split by which curve family they implement:

sgcCrypto_EC covers the NIST curves (P-256, P-384, P-521) and is oriented around PEM keys and JWS signatures, the shape JWT libraries expect (ES256 and similar).

sgcCrypto_ECCurves covers secp256k1 (the Bitcoin and Ethereum curve) and the brainpoolP256r1 / P384r1 / P512r1 curves of RFC 5639, working directly on raw key and signature bytes rather than PEM.

TsgcECCurve in sgcCrypto_ECCurves now names seven curves rather than four: eccSecp256k1, the three Brainpool curves, and eccP256, eccP384 and eccP521 for NIST P-256, P-384 and P-521. The NIST three used to be reachable only through the PEM and JWS API of sgcCrypto_EC, so an application holding a P-256 key as raw bytes had to write PEM first. The raw key generate, sign, verify, ECDH and compress calls below now cover all seven. Reach for sgcCrypto_EC when the job is JWS, and for sgcCrypto_ECCurves when the key material is already bytes.

Both units declare a type named TsgcECCurve and a routine named sgcEC_GenerateKeyPair, with different meanings in each unit. Do not add both units to the same uses clause and reference either name unqualified; qualify with the unit name (sgcCrypto_ECCurves.sgcEC_GenerateKeyPair) if a single unit genuinely needs both.


{ 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 signatures here use RFC 6979 deterministic nonces: the nonce is derived from the private key and the message rather than drawn from a random number generator, which removes the single most common way real deployments have lost an ECDSA private key, a repeated or partially predictable nonce. Signatures carry the low-S normalization that Bitcoin (BIP 62) and Ethereum require; verification accepts either form.

DER encoded ECDSA signatures

One signature, two encodings, and picking the wrong one is among the most common integration bugs in this area. The raw pair R || S, each half the curve field size, is what JOSE and WebAuthn carry. A DER SEQUENCE { r, s } is what X.509, CMS and TLS carry. sgcECDSA_SignHash above produces the raw form, the calls below produce and consume the DER form, and the two converters translate between them without re-signing anything.


{ 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 in the raw key form only picks the HMAC behind the RFC 6979 nonce, it hashes nothing for you, so pass sgcEC_DefaultHash(aCurve) when there is no reason to choose otherwise. sgcECDSA_RawToDER returns an empty array when the pair is not exactly two field size halves, and sgcECDSA_DERToRaw returns False on malformed DER or on a value that does not fit the curve, so both are safe to point at untrusted input.

Exporting and importing EC keys

Three containers, and the PEM label is what tells them apart: a SEC1 ECPrivateKey is BEGIN EC PRIVATE KEY, a PKCS#8 PrivateKeyInfo is BEGIN PRIVATE KEY, and an X.509 SubjectPublicKeyInfo is BEGIN PUBLIC KEY. Java and .NET want PKCS#8, OpenSSL and most command line tooling read either. Neither private form is encrypted, so the result is as secret as the scalar itself.


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;

The import routines read the curve out of the file, which is why aCurve comes back as an out parameter instead of being passed in. A curve the unit does not implement is a negotiation failure rather than a bug, so they return False and never raise, which makes them safe on untrusted input. The public point is recomputed from the scalar when the file leaves it out, and validated against the curve before it is handed back. The export routines raise instead, because key material that does not match the curve is a caller bug and not input.

The dotted named curve OID that goes into each of those containers is available directly, and is what the export and import routines use internally:


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

Always validate a peer's public key

Before using any public key you did not generate yourself, whether for ECDH, ECDSA verification or ECIES, validate that it is actually a point on the curve with sgcEC_ValidatePublicKey. A point that is not on the curve can leak bits of your own private key through the arithmetic used to process it.

Schnorr signatures (BIP-340)

secp256k1 only, and this is what Taproot, Nostr and Lightning use. It is not a drop-in replacement for the ECDSA above, because two things differ. Public keys are x-only, 32 bytes with the y coordinate implied to be even, so a BIP-340 key is not interchangeable with the 33 byte compressed or the 65 byte uncompressed forms the rest of this page produces. And the challenge hashes the public key together with the nonce and the message, which binds the signature to the key in a way an ECDSA signature is not.


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 is 32 bytes of auxiliary randomness mixed into the nonce derivation. It is defence in depth against fault attacks, not the nonce itself: the scheme stays secure with it all zeroes, and an empty array is treated as 32 zero bytes so that the BIP-340 test vectors reproduce exactly. Real use should pass fresh randomness. sgcSchnorr_TaggedHash is exposed because BIP-341 and BIP-342 build further tags on the same construction.