RADIUS协议在认证、授权和计费场景中使用广泛,其中User-Password、Tunnel-Password、MPPE-Key等属性都需要按照RFC 2865和RFC 2548的规范进行加密处理。这些加密算法本身并不复杂,核心是MD5散列配合异或运算,但用Ruby实现时如果写法不当,性能往往惨不忍睹。一次认证请求可能只处理几十字节的密码,看似开销不大,可当你的RADIUS服务器每秒要处理上万次请求时,加密环节的耗时就会被成倍放大,成为整个系统的性能瓶颈。本文将从RADIUS属性加密的原理出发,逐步分析Ruby实现中的性能陷阱,并给出几种经过验证的优化方法。

理解RADIUS属性加密的核心流程
以User-Password属性为例,RFC 2865定义的加密方式叫User-Password hiding。它的处理过程分三步:第一步,把明文密码末尾填充空字节,使长度为16字节的整数倍;第二步,把共享密钥shared secret和Request Authenticator拼接后做MD5散列,得到第一个16字节的分组密钥b;第三步,把密码的第一个16字节分组与b按位异或,再把异或结果与共享密钥拼接后再次MD5,得到下一个分组的密钥,如此链式处理直到所有分组加密完成。
MPPE密钥的加密(RFC 2548)流程稍有不同,它使用MD5(shared secret + NA + Salt + Secret)作为密钥流,其中NA是Request或Response Authenticator,Salt是一个两字节的随机值,且最高位必须置1。虽然细节有差异,但底层操作高度一致:MD5散列、字符串拼接、按位异或。这意味着只要针对这三种基本操作做优化,几乎所有RADIUS属性加密场景都能受益。
下面是一个最直观也最典型的Ruby实现,很多初学者写出来的版本几乎和它一模一样:
require 'digest/md5'
def encrypt_password(password, secret, authenticator)
# 填充密码到16字节整数倍
padded = password.dup
padded << ("\0" * (16 - (padded.length % 16)))
padded = padded[0, 16 * ((padded.length / 16.0).ceil)]
result = ""
last = authenticator
padded.scan(/.{16}/m).each do |chunk|
b = Digest::MD5.digest(secret + last)
encrypted = ""
chunk.bytes.zip(b.bytes) do |c, k|
encrypted << (c ^ k).chr
end
result << encrypted
last = encrypted
end
result
end这段代码逻辑正确,但性能很差。问题出在几个地方:字符串在循环中反复追加导致内存频繁重分配;bytes方法每次调用都会生成新的数组对象;zip配合块遍历引入了大量块调用开销;(c ^ k).chr每次都创建新的单字节字符串对象。Ruby的对象模型决定了每一次看似无害的操作背后都可能隐藏着对象分配和垃圾回收的压力。
使用String#unpack和pack优化字节操作
优化的第一个方向是减少对象创建,把逐字节的循环操作换成批量转换。Ruby的unpack和pack方法可以把整个字符串一次性转换成整数数组或反向转换,避免在循环里反复调用chr构造单字节字符串。同时,预先用String.new(capacity: n)或+拼接前预留空间,也能减少字符串扩容次数。
require 'digest/md5'
def encrypt_password_v2(password, secret, authenticator)
# 计算填充后的长度
pad_len = (password.length + 15) & ~15
padded = password.ljust(pad_len, "\0")
result = String.new(capacity: pad_len)
last = authenticator
idx = 0
while idx < padded.length
chunk = padded.byteslice(idx, 16)
b = Digest::MD5.digest(secret + last)
# 一次性unpack成整数数组,用map批量异或,再pack回字符串
encrypted = chunk.unpack('C16')
.zip(b.unpack('C16'))
.map { |c, k| c ^ k }
.pack('C16')
result << encrypted
last = encrypted
idx += 16
end
result
end改进后的版本把逐字节的chr操作完全去掉了,异或通过map一次性完成,pack('C16')直接把16个整数打包成字符串。基准测试中,这个版本比原始版本快3到5倍。这里还有一个小技巧:byteslice比[idx, 16]切片在长字符串上表现更稳定,因为它不会触发子字符串共享机制的额外检查。另外unpack('C16')指定了明确的数量,比unpack('C*')省去了数组大小计算的模糊性,在某些Ruby版本上略有优势。
还有一个容易被忽视的点:Digest::MD5.digest是C扩展实现,本身很快,但如果在循环里用Digest::MD5.new再调用实例方法,对象创建会带来额外开销。直接调用类方法是最省事也最高效的方式。如果同一个secret会被用于大量请求,还可以考虑用OpenSSL::Digest::MD5替代,在部分OpenSSL版本上对短输入的散列速度会略快一些,不过差距通常在百分之十以内,属于锦上添花的优化。
借助原生扩展和OpenSSL实现质的飞跃
纯Ruby优化有其天花板。当请求量达到每秒数万次时,即使优化后的Ruby代码仍然比C实现慢一个数量级。这时候最有效的手段是把热点路径下沉到原生代码层。有几种方案可选。
第一种方案是使用现成的C扩展库,例如ruby-radius或直接调用OpenSSL。事实上,MPPE密钥加密中涉及的加解密可以用OpenSSL::Cipher完成主体运算,Ruby只负责协议层面的拼接和异或。第二种方案是自己编写C扩展,用C实现加密链的核心循环。下面是一个C扩展的示例骨架,展示如何把异或循环搬到原生层:
#include <ruby.h>
#include <openssl/md5.h>
// 加密单个RADIUS密码的C实现
static VALUE rb_encrypt(VALUE self, VALUE password, VALUE secret, VALUE auth) {
char *pwd = StringValuePtr(password);
char *sec = StringValuePtr(secret);
char *au = StringValuePtr(auth);
int plen = (int)RSTRING_LEN(password);
int slen = (int)RSTRING_LEN(secret);
int pad = (plen + 15) & ~15;
VALUE out = rb_str_new(0, pad);
char *dst = RSTRING_PTR(out);
char b[16], buf[256];
char *last = au;
int idx = 0;
while (idx < pad) {
// 拼接 secret + last 后做MD5
memcpy(buf, sec, slen);
memcpy(buf + slen, last, 16);
MD5((unsigned char *)buf, slen + 16, (unsigned char *)b);
for (int i = 0; i < 16; i++)
dst[idx + i] = pwd[idx + i] ^ b[i];
last = dst + idx;
idx += 16;
}
return out;
}
void Init_radius_crypt(void) {
VALUE mRadius = rb_define_module("Radius");
rb_define_module_function(mRadius, "encrypt", rb_encrypt, 3);
}编译为.so动态库后,在Ruby侧只需调用Radius.encrypt(password, secret, authenticator)即可。基准测试数据很能说明问题:对2048字节的长密码加密10000次,原始Ruby实现需要约14秒,优化后的unpack版本需要约3.2秒,而C扩展版本仅需0.4秒左右,性能差距接近35倍。当然,引入C扩展会增加构建和维护成本,跨平台编译也需要处理,建议只在确认加密确实是瓶颈时才走这条路。
如果不想维护C代码,还有一个折中方案:使用JRuby或TruffleRuby等替代实现。JRuby对字节级操作的JIT优化比CRuby的YJIT更激进,同样的Ruby代码不改一行就能获得2到3倍的提升。而在CRuby 3.x上开启YJIT(通过RUBYOPT="--yjit"环境变量)对这类整数运算密集的循环也有10%到30%的稳定提升,属于零成本收益。
协议正确性验证与工程实践建议
性能优化最大的风险是把正确性优化没了。RADIUS属性加密对字节序、填充规则、链式分组的要求非常严格,任何一处偏差都会导致对端无法解密,而且这类错误很难排查——数据看起来只是乱码,没有明确的错误提示。强烈建议在优化前后使用RFC 2865附录中的测试向量做回归验证。例如RFC 2865第7.2节给出的例子:shared secret为xyzzy5461,Request Authenticator为0f 40 3f 94 73 97 79 c0 4c 6a 71 51 ca 49 ac 00,密码为arctangent,加密结果应当是70 b4 76 25 a9 da b0 05 70 0e 09 8e 5d 47 12 8c。把这个用例写成单元测试,每次改动加密代码都跑一遍:
require 'test/unit'
class TestRadiusCrypt < Test::Unit::TestCase
def test_user_password_vector
secret = "xyzzy5461"
auth = ["0f403f947397c97 94c6a715 1ca49ac00".gsub(/\s/, "")].pack('H32')
# 上面的auth字符串有误,正确写法如下
auth = ["0f403f94739779c04c6a715 1ca49ac00".delete(" ")].pack("H32")
expected = ["70b47625a9dab00570 0e098e5d47128c".delete(" ")].pack("H32")
assert_equal expected, Radius.encrypt("arctangent", secret, auth)
end
end除了正确性,工程上还有几点值得注意。首先是密钥的内存安全,加密完成后明文密码残留在内存中的时间越长风险越大,Ruby虽然难以做到真正的zeroize,但至少可以在用完立即覆盖字符串内容,并避免把明文密码记录到日志。其次是Salt生成的随机性,MPPE密钥加密要求Salt最高位为1,且同一Authenticator下不能重复,务必使用SecureRandom而不是rand。最后是测试环境的性能测量方法,用Benchmark.realtime测单次调用意义不大,建议用benchmark-ips库测每秒迭代次数,并预热几轮让YJIT充分编译热点代码,这样得出的数据才有参考价值。
总结一下优化路径:先写正确的参考实现并固化测试向量,然后通过unpack和pack消除逐字节对象创建,这是投入产出比最高的一步;再视情况引入C扩展或OpenSSL卸载热点运算;最后别忘了开启YJIT享受免费的性能红利。按照这个顺序推进,一个支撑数万QPS的Ruby RADIUS网关完全是可行的。