Delphi RSA加密算法原理与实战实现:从数学基础到工程应用

Delphi RSA加密算法原理与实战实现:从数学基础到工程应用

1. 项目概述:为什么在Delphi里折腾RSA?

如果你是一个用Delphi做桌面应用、服务端接口或者移动端App的开发者,那么“加密”这个词你肯定不陌生。从用户密码的传输,到本地配置文件的保护,再到与第三方API通信时的数据安全,加密都是绕不开的一环。在众多非对称加密算法中,RSA以其成熟、可靠和广泛的支持度,成为了我们最常用的工具之一。这个项目,就是要在Delphi这个经典的开发环境中,从零开始,把RSA加密算法的原理吃透,并把它变成一个可以随手拿来用的实战工具。

你可能会问,现在各种语言都有现成的加密库,Delphi也有自带的TIdSSLIOHandlerSocketOpenSSL或者第三方组件,为什么还要自己动手实现?原因很简单:知其然,更要知其所以然。当你面对“Navicat激活提示RSA public key not find”或者“WinSCP生成SSH RSA密钥失败”这类问题时,如果你对RSA的密钥格式、填充方式、数据块处理一知半解,那排查起来就像在迷宫里打转。自己动手实现一遍,哪怕是最基础的版本,也能让你对密钥对生成、加密解密、签名验签的每一个字节都了如指掌。这对于调试加密通信、集成第三方认证(比如Delphi RSA认证场景)、甚至理解FastReport等组件可能涉及的许可验证机制,都有莫大的帮助。

这次实战,我们不依赖复杂的OpenSSL动态库(虽然最终生产环境推荐用它),而是从数学原理出发,用Delphi的代码来模拟RSA的核心过程。我们会生成自己的密钥,加密一段字符串,再把它解密回来,最后再聊聊如何安全地管理这些密钥。整个过程,就像在Delphi这个老伙计的工坊里,亲手打造一把属于自己的数字锁。

2. 核心原理与Delphi实现思路拆解

2.1 RSA算法的心脏:数学原理简述

RSA的安全性建立在大数分解的困难性上。简单来说,给你两个非常大的质数p和q,把它们乘起来得到n很容易;但反过来,给你这个巨大的n,让你找出原来的p和q,以目前计算机的计算能力,在有限时间内几乎不可能完成。这就是RSA的基石。

整个算法围绕几个核心参数展开:

  1. n(模数): 等于质数p和q的乘积,即n = p * q。这是公开信息的一部分,决定了密钥的长度(如n是2048位,就是常说的2048位RSA密钥)。
  2. φ(n)(欧拉函数): 小于n且与n互质的正整数的个数。对于两个质数p和q,φ(n) = (p-1)*(q-1)。这个值是绝对保密的,是私钥生成的关键。
  3. e(公钥指数): 选择一个整数e,满足1 < e < φ(n),且e与φ(n)互质(最大公约数为1)。通常取65537(0x10001),因为它二进制表示中1很少,计算效率高且安全。
  4. d(私钥指数): 计算e对于φ(n)的模反元素d,即满足(e * d) % φ(n) = 1。d就是私钥的核心,必须严格保密。

有了这些,公钥就是(n, e),私钥就是(n, d)。加密过程是:密文c = m^e mod n(m是明文,需要先转换成数字)。解密过程是:明文m = c^d mod n

注意: 这里的m必须小于n。由于n很大,实际应用中,我们加密的并不是整个文件,而是用一个随机生成的对称密钥(比如AES密钥)去加密文件本身,再用RSA公钥加密这个对称密钥。这就是常见的“RSA+AES”混合加密模式,兼顾了非对称加密的安全性和对称加密的速度。

2.2 Delphi实现的挑战与选型

在Delphi中实现RSA,最大的挑战在于“大数运算”。标准整数类型(如Int64)远远无法容纳1024位或2048位的大整数。因此,我们需要一个可靠的大数计算库。

方案选择

  1. 使用OpenSSL: 最强大、最标准的生产级方案。通过动态链接库(DLL)调用其函数。这是处理像“前端RSA+AES加密安全吗”这类企业级安全需求时的首选。但它的缺点是依赖外部库,部署稍麻烦,且不利于我们理解底层原理。
  2. 使用纯Pascal大数库: 例如DelphiBigNumTFGInt等。这些库用Object Pascal实现,无需外部依赖,非常适合教学和原理演示。本次实战我们将采用这个方向,因为它能让我们看清每一步计算。
  3. 使用系统CryptoAPI: Windows平台可用,但跨平台性(如Delphi Android)支持不佳。

我们选择方案二,并假设使用一个类似TBigInteger的类(你可以从开源社区找到或自己实现基础功能)来代表大整数。我们的目标是实现一个最小功能集的RSA类,包含密钥生成、加密、解密。

核心设计思路

  • 定义一个TRSA类。
  • 私有字段存储p,q,n,φn,e,d
  • 公开方法:GenerateKeys(生成密钥),EncryptString/DecryptString(字符串加解密),GetPublicKey/GetPrivateKey(获取密钥文本)。
  • 内部依赖一个BigNumber单元进行加减乘除、模幂运算。

3. 密钥生成与核心算法实现细节

3.1 质数生成:寻找安全的p和q

生成RSA密钥的第一步是找到两个大质数p和q。在真实环境中,这需要复杂的素性检测算法(如米勒-拉宾素性测试)。为了简化演示,我们可以使用一个预置的较小质数列表,或者实现一个简单的试除法(仅适用于教学小数字)。

// 假设我们有一个函数 IsPrime(n: TBigInteger): Boolean // 和一个函数 GenerateRandomBigInt(BitLength: Integer): TBigInteger procedure TRSA.GenerateKeys(BitLength: Integer); var pBits, qBits: Integer; begin // 通常p和q的长度约为总长度的一半 pBits := BitLength div 2; qBits := BitLength - pBits; // 循环直到找到质数 repeat Fp := GenerateRandomBigInt(pBits); until IsPrime(Fp); repeat Fq := GenerateRandomBigInt(qBits); // 确保p和q不相等,且差值较大以增加安全性 until IsPrime(Fq) and (not Fp.Equals(Fq)); // 计算 n = p * q Fn := Fp.Multiply(Fq); // 计算 φ(n) = (p-1)*(q-1) FPhiN := (Fp.Subtract(TBigInteger.One)).Multiply(Fq.Subtract(TBigInteger.One)); // 选择公钥指数 e,通常为65537 Fe := TBigInteger.ValueOf(65537); // 确保 e 与 φ(n) 互质 if not FPhiN.GCD(Fe).Equals(TBigInteger.One) then raise Exception.Create('选择的e与φ(n)不互质,请调整质数。'); // 计算私钥指数 d = e mod φ(n) 的模反元素 // 即求满足 e*d ≡ 1 (mod φ(n)) 的 d Fd := Fe.ModInverse(FPhiN); end;

实操心得

  • 质数质量: 教学代码中的IsPrime函数如果是简单试除,千万不能用于真实环境。真实项目必须使用密码学安全的随机数生成器和强素性测试。
  • 密钥长度BitLength参数通常为1024、2048或4096。1024位目前已不被推荐用于新的系统,2048位是当前的标准选择。这直接关系到“Navicat激活”或“WinSCP生成SSH RSA密钥”时密钥的强度。
  • e的选择: 固定使用65537除了效率高,还能抵抗一些针对小e的潜在攻击。

3.2 模幂运算:加密解密的核心引擎

加密c = m^e mod n和解密m = c^d mod n都需要进行“模幂运算”。直接先求幂再取模,对于大数来说计算量爆炸且中间结果会溢出。必须使用“快速模幂算法”(Exponentiation by Squaring)。

function ModPow(ABase, AExponent, AModulus: TBigInteger): TBigInteger; var result, base, exponent: TBigInteger; begin result := TBigInteger.One; base := ABase.Mod(AModulus); // 先取模,减少计算量 exponent := AExponent; while (exponent.CompareTo(TBigInteger.Zero) > 0) do begin // 如果指数是奇数,乘一次底数 if (exponent.TestBit(0)) then // 检查最低位是否为1 begin result := (result.Multiply(base)).Mod(AModulus); end; // 底数平方 base := (base.Multiply(base)).Mod(AModulus); // 指数右移一位(除以2) exponent := exponent.ShiftRight(1); end; Result := result; end;

这个算法将时间复杂度从O(n)降低到了O(log n),是能够实际执行的关键。

3.3 数据分块与填充:让算法适应任意数据

原始的RSA算法要求加密的整数m必须小于n。对于字符串或任意数据,我们需要:

  1. 编码: 将字符串(如UTF-8)转换为一个大整数。
  2. 分块: 如果数据转换后比n还大,就必须分块加密。每块的大小(字节数)必须小于密钥模数的字节长度。对于2048位密钥(256字节),考虑到填充,实际每块能加密的数据更少(如OAEP填充下可能只有190多字节)。
  3. 填充: 为了防止确定性加密带来的安全隐患(相同的明文总是加密成相同的密文),并增加结构,必须在加密前对数据块进行填充。常见的填充方案有PKCS#1 v1.5和OAEP。OAEP更安全,但实现更复杂。

一个简化的、不安全的ECB模式分块加密示例(仅用于理解流程)

function TRSA.EncryptBytes(const Data: TBytes): TBytes; var blockSize, keyByteLen, i, j: Integer; inputLen, outputLen: Integer; tempBigInt, encryptedBigInt: TBigInteger; block: TBytes; begin keyByteLen := (Fn.BitLength + 7) div 8; // 模数n的字节长度 // 安全起见,每块明文长度应比keyByteLen少至少11字节(PKCS#1 v1.5填充开销) blockSize := keyByteLen - 11; inputLen := Length(Data); // 计算输出缓冲区大小(密文块大小固定为keyByteLen) outputLen := ((inputLen + blockSize - 1) div blockSize) * keyByteLen; SetLength(Result, outputLen); i := 0; j := 0; while i < inputLen do begin // 1. 取一块数据 SetLength(block, Min(blockSize, inputLen - i)); Move(Data[i], block[0], Length(block)); Inc(i, Length(block)); // 2. 应用填充(这里简化为直接转换为大整数,实际必须用PKCS#1或OAEP) // !!!警告:此处仅为演示,无填充,极其不安全!!! tempBigInt := TBigInteger.Create(1, block); // 将字节数组转为正大整数 // 3. 核心加密:c = m^e mod n encryptedBigInt := ModPow(tempBigInt, Fe, Fn); // 4. 将加密后的大整数转换回定长字节数组 // 大整数转字节数组通常不是定长的,需要补零到keyByteLen block := encryptedBigInt.ToByteArray; // 确保block长度是keyByteLen,不足前面补0(因为大整数表示可能省略前导零) if Length(block) < keyByteLen then begin Insert(block, Result, j + (keyByteLen - Length(block))); Inc(j, keyByteLen); end else if Length(block) = keyByteLen then begin Move(block[0], Result[j], keyByteLen); Inc(j, keyByteLen); end else begin // 理论上加密后长度不应超过keyByteLen,若超过说明有误 raise Exception.Create('加密后数据块长度异常。'); end; end; // 调整结果数组到实际大小 SetLength(Result, j); end;

重要警告: 上面的代码省略了填充(Padding)这一关键安全步骤。没有填充的RSA(教科书式RSA)是不安全的,容易受到多种攻击。真实环境必须实现PKCS#1 v1.5或更好的OAEP填充。这也是回答“前端RSA+AES加密安全吗”时必须考虑的点:如果RSA部分使用了不安全的填充或模式,整个链条就不安全。

4. 完整应用实战:从加密字符串到密钥管理

4.1 封装易用的字符串加解密接口

基于上面的分块加密函数,我们可以封装对字符串更友好的接口。

function TRSA.EncryptString(const PlainText: string; AEncoding: TEncoding): string; var dataBytes, encryptedBytes: TBytes; begin // 1. 字符串转字节 dataBytes := AEncoding.GetBytes(PlainText); // 2. 加密字节数组 encryptedBytes := EncryptBytes(dataBytes); // 3. 将密文字节数组转换为Base64字符串,便于传输和存储 Result := TNetEncoding.Base64.EncodeBytesToString(encryptedBytes); end; function TRSA.DecryptString(const CipherTextBase64: string; AEncoding: TEncoding): string; var encryptedBytes, decryptedBytes: TBytes; tempBigInt, decryptedBigInt: TBigInteger; keyByteLen, blockSize, i, j: Integer; block: TBytes; begin // 1. Base64解码 encryptedBytes := TNetEncoding.Base64.DecodeStringToBytes(CipherTextBase64); keyByteLen := (Fn.BitLength + 7) div 8; blockSize := keyByteLen; // 密文块大小固定为keyByteLen SetLength(decryptedBytes, Length(encryptedBytes)); // 最大可能长度 j := 0; i := 0; while i < Length(encryptedBytes) do begin // 取一个密文块 SetLength(block, blockSize); Move(encryptedBytes[i], block[0], blockSize); Inc(i, blockSize); // 将密文块转换为大整数 tempBigInt := TBigInteger.Create(1, block); // 核心解密:m = c^d mod n decryptedBigInt := ModPow(tempBigInt, Fd, Fn); // 将解密后的大整数转回字节,并去除填充(这里简化处理,直接取字节) block := decryptedBigInt.ToByteArray; // 注意:这里需要根据具体的填充方案来解析,得到真正的明文数据块 // 假设无填充(仅演示),直接追加 Move(block[0], decryptedBytes[j], Length(block)); Inc(j, Length(block)); end; // 设置实际长度 SetLength(decryptedBytes, j); // 字节转字符串 Result := AEncoding.GetString(decryptedBytes); end;

4.2 密钥的导出与导入:PEM格式初探

生成的密钥对(n, e)(n, d)需要持久化。标准格式是PEM,它本质上是Base64编码的DER(ASN.1编码)数据,加上“-----BEGIN PUBLIC KEY-----”这样的头尾标签。

简单实现密钥导出(仅导出n和e/d的十六进制字符串,非标准PEM)

function TRSA.GetPublicKey: string; begin // 简单拼接n和e的16进制字符串,用特定分隔符,如`|` Result := Fn.ToString(16) + '|' + Fe.ToString(16); end; function TRSA.GetPrivateKey: string; begin // 警告:私钥必须加密存储!此处仅为演示。 Result := Fn.ToString(16) + '|' + Fd.ToString(16); end; procedure TRSA.LoadPublicKey(const AKeyStr: string); var parts: TArray<string>; begin parts := AKeyStr.Split(['|']); if Length(parts) <> 2 then raise Exception.Create('公钥格式错误'); Fn := TBigInteger.Create(parts[0], 16); // 从16进制字符串创建 Fe := TBigInteger.Create(parts[1], 16); // 加载公钥后,私钥部分应为空 Fd := TBigInteger.Zero; Fp := TBigInteger.Zero; Fq := TBigInteger.Zero; FPhiN := TBigInteger.Zero; end;

实操心得

  • 私钥安全: 上述GetPrivateKey直接暴露私钥指数d是极其危险的。真实场景中,私钥应以加密形式存储,例如使用密码进行基于口令的加密(PBE)。
  • 标准格式: 与外部系统(如OpenSSL生成的密钥、WinSCP、Navicat)交互,必须使用标准的PEM或DER格式。这需要实现完整的ASN.1编码解码,比较复杂。一个务实的做法是:在Delphi中,对于生产环境,直接使用TIdSSLIOHandlerSocketOpenSSL相关的函数来加载标准的PEM文件,而不是自己解析。

4.3 实战场景串联:模拟一个简单的许可验证

假设我们有一个用Delphi编写的软件,想用RSA来验证一个本地许可证文件是否有效。

  1. 生成密钥对: 在开发端,用强随机源生成一对RSA-2048密钥。公钥硬编码在软件里,私钥自己妥善保存。
  2. 制作许可证: 许可证内容包含“用户名称=张三”和“过期日期=2024-12-31”。用私钥对这个字符串进行签名(签名本质上是先用哈希函数如SHA256处理数据,再用私钥加密这个哈希值)。
  3. 分发: 将许可证明文和签名一起(或合并)发给用户。
  4. 软件验证: 软件启动时,读取许可证文件,分离出明文和签名。使用内置的公钥对签名进行验证(即用公钥解密签名得到哈希值A,同时自己用同样的哈希算法计算明文数据的哈希值B,比较A和B是否一致)。一致则通过。

这个过程就涉及了RSA的另一个核心功能:数字签名。它与加密过程类似,但用私钥签名,公钥验证,确保了数据的完整性和来源认证。这比简单的“RSA认证”一词所涵盖的意义更具体。

5. 常见陷阱、问题排查与进阶思考

5.1 典型错误与调试清单

在集成RSA功能时,你大概率会遇到以下问题:

问题现象可能原因排查思路与解决方案
加密/解密结果不对1. 数据编码不一致(如加密用UTF-8,解密用ANSI)。
2.填充方案不匹配(最常见!)。
3. 分块大小或边界处理错误。
4. 大数运算库的字节序(Big-Endian vs Little-Endian)问题。
1. 统一使用UTF-8编码。
2.确保加密端和解密端使用完全相同的填充标准(如都用PKCS#1 v1.5)。与OpenSSL交互时尤其要注意。
3. 单步调试,检查每块数据转换前后的大整数数值。
4. 检查ToByteArrayCreate(byte[])方法是否遵循相同的字节序约定。
“数据太大”错误明文数据未分块,或分块大小计算错误,导致某块数据转换后的大整数m>=n确保每块明文字节长度满足:明文块长 <= 密钥字节长 - 填充开销。对于PKCS#1 v1.5,开销至少11字节。
与第三方工具(如OpenSSL命令行)结果不一致1. 密钥格式不同(PEM/DER/裸参数)。
2. 填充模式不同。
3. 数据格式不同(是否包含Base64,是否有换行)。
1. 使用openssl rsa -text -in key.pem查看密钥的详细参数(n, e, d),与你代码生成的对比。
2. 明确指定填充,如OpenSSL的-pkcs1
3. 使用echo -n "data" | openssl enc -base64确保数据源一致。
性能极慢使用了未优化的模幂运算(如直接求幂再取模),或质数生成算法效率低下。1.必须使用快速模幂算法(如前文ModPow)。
2. 对于生产环境,生成密钥这种一次性操作,应使用OpenSSL等成熟库。
在Delphi Android上运行异常可能使用了仅限Windows的API(如CryptoAPI),或大数库在移动平台有兼容性问题。1. 选择纯Pascal实现且支持移动平台的大数库。
2. 考虑在移动端使用系统提供的安全API(如Android的KeyStore)。

5.2 关于“前端RSA+AES加密安全吗?”的深度思考

这个热词反映了一个常见架构:浏览器端用RSA公钥加密一个随机生成的AES密钥,然后用这个AES密钥加密实际请求数据,将两者一起发给服务端。服务端用RSA私钥解密出AES密钥,再用它解密数据。

安全性分析

  • 优点: 结合了RSA的非对称特性和AES的速度,解决了密钥分发问题。
  • 风险点
    1. RSA部分: 如果前端使用的JavaScript库存在弱随机数生成器、使用了不安全的填充模式(如教科书式RSA)、或者密钥长度不足(如1024位),则RSA环节可能被攻破。
    2. AES部分: 需要正确选择模式(如GCM)和初始化向量(IV),否则可能泄露信息。
    3. 整体性: 缺乏对完整消息的签名,无法防止密文被篡改或重放。
  • 结论: 方案本身是安全的模式,但安全性取决于实现细节。必须确保:RSA使用OAEP填充和足够长的密钥(2048位以上),AES使用GCM等认证模式,并且整个通信过程最好在TLS(HTTPS)之上进行,以提供额外的传输层保护。Delphi后端在实现对应的解密逻辑时,必须与前端的加密库在每一个细节(填充、数据格式)上精确匹配。

5.3 进阶方向:从演示代码到生产代码

我们手写的这个RSA类,是一个绝佳的学习工具,但离投入生产还有距离。下一步你应该:

  1. 集成真正的密码学库: 放弃自己实现的大数运算和质数生成,转而使用TIdSSLIOHandlerSocketOpenSSL背后绑定的OpenSSL,或者Delphi的TNetEncrypt(如果版本支持)。这是最安全、最省力的方式。
    uses System.NetEncoding, System.Hash, Soap.EncdDecd; // 可能需要这些单元 // 使用OpenSSL的EVP接口进行RSA操作是更佳实践
  2. 实现标准PEM读写: 研究如何解析和生成标准的PEM格式密钥文件,这将使你能够无缝使用OpenSSL命令行工具生成的密钥,也能处理“WinSCP生成的SSH RSA密钥”或“Navicat激活”相关的密钥问题。
  3. 添加完整的填充方案: 实现PKCS#1 v1.5和OAEP填充。这需要仔细阅读RFC文档,并处理复杂的编码操作。
  4. 探索签名与验证: 实现SHA256WithRSA等签名算法,用于实际的许可证验证或API请求签名。

在Delphi的世界里深入实践一次RSA,就像给这位久经沙场的老将配上了一把现代化的密码锁。你收获的不仅仅是几行能加密解密的代码,更是一套排查加密相关问题的底层思维模型。下次再遇到“RSA public key not find”之类的错误时,你首先会去检查密钥的格式、编码和完整性,而不是盲目地重装软件。这种从原理到实战的穿透式理解,正是资深开发者与普通调用者的分水岭。