公钥(非对称)密码学使用一对密钥,而不是一个共享密钥:凡是私钥签署的内容,都能用对应的公钥验证; 凡是被密封给某个公钥的内容,只有对应的私钥才能打开。公钥可以交给任何人,包括潜在的攻击者, 而不会削弱任何安全性。
RSA
密钥生成需要搜索大素数,速度确实较慢,生成一个 2048 位密钥可能需要几秒钟;切勿在 UI 线程中运行它。 加密请使用 OAEP,签名请使用 PSS,二者都包含 PKCS#1 v1.5 所缺少的随机化元素。 sgcCrypto_RSA 中提供了 PKCS#1 v1.5 签名/验证,用于与按名称指定它的格式互操作(常见场景是 JWS 的 RS256);任何新场景都应优先选择 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 v1.5 加密同样提供,即 Java 命名为 RSA/NONE/PKCS1Padding 的模式。保留它只是为了与强制要求使用它的格式互操作,别无其他用途:v1.5 填充正是 Bleichenbacher 攻击所针对的目标,因此任何新场景都应继续选择 OAEP。明文长度被限制为模数长度减去 11 字节, 使用 2048 位密钥时为 245 字节。
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 对每一种解码失败都会返回 False, 并且对所有失败情形都走同一条路径,因为把它们区分开来正是填充预言攻击所需要的。
带独立 MGF1 哈希的 OAEP
OAEP 在两个地方使用哈希:一次用于标签,一次用于 MGF1 掩码生成函数内部,二者不必相同。 这正是 Java 容易踩坑的地方。SunJCE 把 OAEPWithSHA256AndMGF1Padding 解读为摘要使用 SHA-256、而 MGF1 仍固定为 SHA-1;Bouncy Castle 却把同一个字符串解读为两者都是 SHA-256, 因此其中一方生成的密文无法被另一方解密。五参数重载版本可以分别锁定这两个哈希, 这正是与 Java 对端通信时可能需要的做法。四参数形式保持不变,将 aHash 同时用于两处。
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;
哈希不匹配与密文损坏是无法区分的,因此错误的 aMGFHash 只会返回一个普通的 False,不会指出究竟是哪一个哈希出了错。 通信双方必须事先就此达成一致。
密钥可以导出为 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;
证书本身并不是公钥,因此应把从证书中提取出的 SubjectPublicKeyInfo 交给 sgcRSA_ImportPublicKeyDER, 而不是整张证书。sgcRSA_CheckPrivateKey 会验证来自外部的任何密钥的内部一致性:n = p*q 是否成立、e*d 是否与 1 同余,以及 CRT 参数是否匹配。
Ed25519 和 Ed448(签名)
EdDSA 的随机数是由私钥和消息确定性地派生出来的,因此签名过程中不涉及任何随机数生成器, 也就不存在因随机数重复而泄露私钥的可能,这一点与 ECDSA 不同(见下文)。 sgcCrypto_Ed25519 是 sgcWebSockets 其他地方 WebAuthn EdDSA 凭据类型所使用的验证/签名原语; 它要求调用方已经知道 32 字节公钥(WebAuthn 会从凭据中把公钥交给您), 并不暴露单独的密钥生成或公钥派生调用。sgcCrypto_Ed448 是通用签名器,安全强度约为 224 位,而 Ed25519 约为 128 位,代价是密钥和签名更大、签名速度更慢。
{ 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(密钥协商)
基于蒙哥马利曲线的 Diffie-Hellman:双方各自生成一对密钥,交换公钥,然后各自根据自己的私钥 和对方的公钥计算出相同的共享密钥。在把原始输出用于加密任何内容之前,请务必先通过 KDF 处理 (参见密码哈希与密钥派生页面中的 HKDF);原始 ECDH 输出本身 并不适合直接用作密钥。X448 的安全强度约为 224 位,而 X25519 约为 128 位,代价是密钥更大, 且计算量大约是三倍。
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 会返回 False; 低阶密钥是一小类特殊公钥,无论您自己的私钥是什么,都会导致共享密钥全部变为零。 请务必检查返回结果,而不要无条件信任其输出。
ECIES(密封盒加密)
将 X25519、HKDF-SHA256 和 XChaCha20-Poly1305 组合成一次调用,即可把一条消息加密给接收方的公钥, 而无需事先存在共享密钥。aContext 会把密封消息与某个用途或协议步骤绑定在一起,用不同的 context 打开会失败, 这样就能阻止把某次交换中密封的消息重放到另一次交换中。
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(比特币和以太坊所用曲线)以及 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 确定性随机数:随机数由私钥和消息派生,而不是从随机数生成器中取得, 这消除了实际部署中最常见的一种私钥泄露方式,即随机数重复或部分可预测。签名带有比特币(BIP 62) 和以太坊都要求的 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 nonce 背后所用的 HMAC,并不会为您哈希任何数据,因此在没有特殊理由选择其他值时, 请传入 sgcEC_DefaultHash(aCurve)。 当输入对不是恰好两个域大小的一半时,sgcECDSA_RawToDER 会返回一个空数组;而 sgcECDSA_DERToRaw 在遇到格式错误的 DER 或不符合曲线的数值时会返回 False, 因此二者都可以安全地用于处理不受信任的输入。
导出和导入 EC 密钥
三种容器,PEM 标签正是区分它们的依据:SEC1 的 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 参数的形式返回,而不是作为传入参数。 单元未实现的曲线属于协商失败而非 bug,因此它们会返回 False 而绝不引发异常,这使它们可以安全地用于不受信任的输入。当文件省略公钥点时会根据标量重新计算, 并在返回之前针对曲线进行验证。导出例程则会引发异常,因为与曲线不匹配的密钥材料属于调用方的错误, 而不是输入数据的问题。
写入上述每种容器所需的带点分隔命名曲线 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 坐标的 32 字节形式,隐含 y 坐标为偶数,因此 BIP-340 密钥无法与本页其余部分 生成的 33 字节压缩形式或 65 字节非压缩形式互换。此外,挑战值会将公钥与 nonce 和消息一起哈希, 这以 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 是混入 nonce 派生过程的 32 字节辅助随机数。它是针对故障攻击的纵深防御手段,而不是 nonce 本身:即使它全为零,方案依然安全, 空数组也会被当作 32 个零字节处理,这样 BIP-340 的测试向量才能被精确复现。实际使用时应传入真正的新鲜随机数。 sgcSchnorr_TaggedHash 之所以被单独导出, 是因为 BIP-341 和 BIP-342 会在同一构造之上构建更多的标签。