文末本文转载自微信公众号「DotNET技术圈」,作者邹溪源。转载本文请联系DotNET技术圈公众号。
概述
在传统企业级开发中,消息队列机制已经成为一种非常常见的技术实现手段,而基于NetMQ则看起来有点像一朵“奇葩”,看起来从名字似乎是一个消息队列(Message Quene),但事实上更多的却是一个类似于socket机制的消息库。它虽然提供了消息队列的能力,但又与传统消息队列中间件如kafka、rabbitmq等有一定的区别。
不过,不管它是啥,它提供的一些类似于消息队列的机制,使得开发者能够快速在项目中使用起来,例如类似于发布订阅模式、推拉模式等机制,接入简便,功能也挺强大。而且当如果我们要实现消息加密时,还可能通过一些简单的操作实现,例如我们可以选择对内容进行Rsa加密,或者也许还有其他的实现方法?
TL;DR:本文首先介绍NETMQ及其常用的使用模式,进而讨论如何基于NETMQ实现消息的加密传输机制。
NetMq简介和基本特性
ZeroMQ
NETMQ是一种轻量级的消息队列组件,是著名的ZeroMQ的重要成员。2010年,AMQP的最初设计者Pieter Hintjens带领其团队退出了该开源项目,并发起成立了ZeroMQ这个新的消息库,并发展至今。Pieter Hintjens 后由于胆管癌复发,于2016年接受了安乐死。
在ZeroMQ的官方网站中,其介绍到ZeroMQ看起来似乎是一个消息队列框架,实际上更像一个并发处理框架。它除了提供了多种消息队列机制(如Pub-Sub、Pull-Push、Dealer、XPub-XSub机制)外,更是为开发者提供了跨多种传输能力的套接字,它不仅适用于进程间的消息传输,也同样适用于进程内、TCP和多播的传输机制,基于其提供的框架,开发者能快速的实现原子消息的传输能力。ZeroMQ的轻量级体现在其框架灵活简单,性能优异,无需依赖外部组件,即可轻松实现优秀的性能。它也支持异步I/O的传输机制,可为多核应用程序提供扩展,且能成为集群部署的核心传输组件。
ZeroMQ提供了多种语言实现,参见其官方网站,包括C语言,C#,Java等主流后端语言,都支持良好,同样,也支持包括Go、Node.JS等最近比较热门的新兴语言。ZeroMQ自然也支持不同语言间的数据传输,使其可以成为跨语言传输的一种消息协议。
ZeroMQ的Zero,代表一种极简文化,可以代表零代理层(与Mqtt等队列机制不同,ZeroMQ提供的是一种无代理层的队列机制),零延迟,零成本和零管理。ZeroMQ致力于打造极简的通信组件,通过消除组件的复杂性来提升其功能应用效果。
NETMQ和ClrZmq
对于C#开发者来说,可以使用NetMQ和ClrZmq两种不同的方式来获得ZeroMQ的魔力,前者是基于C#语言原生实现的ZeroMQ通信协议,后者则是通过C#调用基于C语言实现 的Libzmq库来使用。
相对而言,前者可能更受欢迎。NETMQ也同样继承了ZeroMQ的优雅性能和轻量化,开发者可通过Nuget下载NetMQ的的组件,通过几行代码就可以集成消息队列和套接字传输能力。如图所示,NetMQ获得了约175w的下载量,算是一个比较受欢迎的基础组件。
而同样在nuget上,ClrZmq的下载量则远远少于NetMQ,仅仅8w多的下载量,可能说明它只是一种小众框架吧。值得一提的是,ClrZmq需要根据构建平台来选择不同的架构。
NETMQ的组成部分
截止本文撰写时,NETMQ的版本为4.0.1.6,作为轻量级组件的一个评判标准,依赖项复杂度也是个重要指标,而NetMQ只依赖了AsyncIO、NaCI.NET、System.ServiceModel.Primitives、System.Threading.Tasks.Extension、System.ValueTuple五个组件,算是名副其实,此处重点介绍两个非System开头的组件。
AsyncIO:该组件是一个高性能的异步的消息套接字库,事实上在Nuget上,该消息库比NetMQ更受欢迎,基于该组件,可减少套接字开发的成本。
NaCI:该组件是一个加密组件,实现了包括Curve25519x、Salsa20、Poly1305加密算法。Curve25519是一种椭球曲线加密算法,被设计用于椭圆曲线迪菲-赫尔曼(ECDH)密钥交换方法。Salsa20是一种流加密算法。Poly1305是一种消息认证码,可用于检测消息的完整性和验证消息的真实性,现常在网络安全协议(SSL/TLS)中与salsa20或ChaCha20流密码结合使用。这三种算法都是由密码专家丹尼尔·J·伯恩斯坦设计的加密算法。
这两个组件都是由NETMQ的创建者somdoron[1](Doron Somech)创建,并引入到NETMQ中。
官方网站
NETMQ官方网站地址为https://netmq.readthedocs.io/,该网站提供了较为完整的学习示例,开发者可参考该示例快速学会该组件的用法。
常见模式实现
NETMQ提供了多种消息通信机制,例如发布订阅模式,推拉模式,
发布订阅模式(Pulish-Subscriber Pattern)
简介
发布-订阅是一种消息传递模式,其中消息的发送者(称为发布者)不会将消息编程为直接发送给特定的接收者(称为订阅者)。发布的消息按照主题进行特征化,作为发布者事先不用知道可能有哪些订阅者(如果有)。
类似地,订阅者可订阅多个主题,也可只订阅一个主题。订阅者也同样无需关注发布者是否真实存在,不过由于ZeroMQ本身没有代理层,且需要绑定服务端端口,事实上看起来似乎必须给定发布者。但由于ZeroMQ本身也可以作为一种微服务架构的基础设施[2],实际上也可以通过一些机制,例如消息代理,地址代理,DNS网关如ZeroConf,Gossip协议等机制,将发布者隐藏在消息网关背后,从而使得订阅者无需关注发布者具体在哪里。
代码示例
该需要首先创建一个发布者,并通过主题的形式发布消息。
- class Program
- {
- private static string _address = "";
- static void Main(string[] args)
- {
- Console.WriteLine("Hello World!");
- _address = "tcp://127.0.0.1:5556"; //设置端口
- var task = Task.Factory.StartNew(async() =>
- {
- await BeginPublisherAsync();
- });
- var taskSubScriber = Task.Factory.StartNew(() =>
- {
- BeginSubscriberSocket();
- });
- while(Console.ReadKey().Key!=ConsoleKey.Escape);
- }
- /// <summary>
- /// 启动消息发布
- /// </summary>
- /// <returns></returns>
- private static async Task BeginPublisherAsync()
- {
- using (var publisher = new PublisherSocket())
- {
- publisher.Bind(_address); //绑定端口
- while (true)
- {
- publisher
- .SendMoreFrame("DotNET技术圈") // Topic
- .SendFrame("test"); // Message
- await Task.Delay(TimeSpan.FromSeconds(1));
- }
- }
- }
- /// <summary>
- /// 订阅消息
- /// </summary>
- private static void BeginSubscriberSocket()
- {
- using (var subscriber = new SubscriberSocket())
- {
- subscriber.Connect(_address);
- subscriber.SubscribeToAnyTopic();
- while (true)
- {
- var topic = subscriber.ReceiveFrameString();
- var msg = subscriber.ReceiveFrameString();
- Console.WriteLine("收到消息: {0} {1}", topic, msg);
- }
- }
- }
- }
在上述代码中,发布者绑定了tcp://127.0.0.1:5556端口,并通过同步阻塞的方式,发布主题为Topic的消息内容。也可以指定主机的固定ip地址来进行消息发布,还能通过inproc://inproc-demo的方式进行进程内通信。
- using var subscriber = new SubscriberSocket()
- subscriber.Connect("tcp://127.0.0.1:5556");
- subscriber.Subscribe("TopicA"); //订阅到TopicA主题,也可通过SubscribeToAnyTopic订阅所有主题,也可通过UnSubcribe取消订阅相关主题
- while (true)
- {
- var topic = subscriber.ReceiveFrameString();
- var msg = subscriber.ReceiveFrameString();
- Console.WriteLine("From Publisher: {0} {1}", topic, msg);
- }
请求响应模式(Request-Response Pattern)
请求响应模式也是NETMQ众多消息模式中最为简单的一种模式,这种模式实际上有点像http协议,可通过一问一答的同步阻塞的模式进行消息的应答,当然,发送HTTP请求我们也可以不必接收响应,NETMQ的请求响应模式也同样如此。
示意图
- private static void BeginResponseSocket()
- {
- using var responseSocket = new ResponseSocket(_address);
- string request=responseSocket.ReceiveFrameString();
- responseSocket.SendFrame("Hello DotNET技术圈");
- }
- private static async Task BeginRequestSocketAsync()
- {
- using var requestSocket = new RequestSocket();
- requestSocket.Connect(_address);
- while (requestSocket.TrySignalOK())
- {
- try
- {
- requestSocket.TrySendFrame("Hallo I am DotNET技术圈码农");
- requestSocket.TrySendFrame("Hallo I am DotNET技术圈码农"); ---这里会引发错误。。
- }
- catch(Exception ex)
- {
- Console.Out.WriteLine(ex);
- }
- await Task.Delay(1000);
- }
- }
由于该模式的同步阻塞特性,如果同时发送两条消息,可能会触发NETMQ重复发送异常,如:
推拉模式
推拉模式与我们传统意义上理解的类似于手机推送的模式有一些区别,ZeroMQ中说该模式主要将消息下发到提供了一组Push-Pull的套接字,实现消息下发。
值得一提的是,即便的同为ZeroMQ模式下不同语言的版本,对于相同模式的说明,文字描述也不尽相同,例如,在NetMQ的开发者文档中,
Well a PushSocket is normally used to push to a PullSocket, whilst the PullSocket will pull from a PushSocket. Sounds obvious right!
PushSocket 负责把消息推给PullSocket,同样PullSocket负责从PushSocket 拉消息。
这样的说明似乎什么都说了,但又似乎啥都没说,看看其他语言的实现,例如基于Python的PyZmq中,其描述为这样:
Push and Pull sockets let you distribute messages to multiple workers, arranged in a pipeline. A Push socket will distribute sent messages to its Pull clients evenly. This is equivalent to producer/consumer model but the results computed by consumer are not sent upstream but downstream to another pull/consumer socket.
推拉模式允许你基于通过管道的机制实现消息分发给多个工作者。单个PushSocket分发会将消息均匀的分发给其Pull客户端。这样的操作等效于生产者-消费者模型,但消费者计算的结果不是向上发送,而是向下游发送到另一个拉/消费者套接字。
两种不同的实现,在描述上区别还是显著不同,通过两者的对比,我们可以这样理解:Push-Pull模式下,两者都可以互为服务端或客户端,但无论如何,其消息都是单向传输的。消息总是沿着管道向下流动,沿着我们设计的方向传输,实现消息在不同节点间的负载均衡。
例如,可以实现如下的效果,通过一个Ventilator来生产数据,通过多个Pull来拉取数据,进而实现数据向下流动,可以参考NetMq官方文档来实现该代码。
基于推拉模式,可以设计非常负责的业务模型,例如类似于MapReduce的数据处理器[3]就是一个这样的教学工具。(当然,该工具只是一个演示ZeroMQ模式实现的分布式计算的Demo,可能不适合作为生产用途)。
代码示意
本示例中,仅仅简单介绍Push-Pull的用法,暂不涉及复杂的模式。
- private static async Task BeginPushSocketAsync()
- {
- using var pushSocket = new PushSocket(_address);
- while (true)
- {
- pushSocket.SendFrame("Hello Clients");
- await Task.Delay(1000);
- }
- }
- private static async Task BeginPullSocketAsync()
- {
- using var pullSocket = new PullSocket(_address);
- while (true)
- {
- string message = pullSocket.ReceiveFrameString();
- Console.WriteLine(message);
- await Task.Delay(1000);
- }
- }
netmq加密传输机制实现
当我们使用NetMQ进行消息传输时,上述示例均没有对消息进行任何加密处理,这种策略可能导致一些不可控的安全性风险,例如在开发基于NetMQ的聊天室功能时,发布的信息若未采取任何加密措施,事实上可能意味着消息是以广播的形式对外发布,从而会造成某些隐私信息泄漏。或者,如果你需要向外Publish某些消息,未授权的订阅者订阅了你的数据,虽然可能数据中不包含直接的隐私数据,但同样可能会引起你的不适。
因此,从安全性的角度来说,无论你计划基于NetMQ实现何种场景,事实上可能都得考虑以尽可能安全的形式“发布”你的消息。目前我们可通过三种方式来实现消息的加密传输功能。第一种是使用基于Tls协议的NetMQ.Security组件,一种是基于非对称密钥算法,如RSA加密算法,还有一种是基于ZeroMQ所提供的两种加密方式,ECC椭球曲线加密算法和Z85加密算法,以对称密钥的方式。
基于Tls的NetMq.Security?
NetMQ.Security[4]也是由NetMQ的主要贡献者somdoron开发的组件,目前该组件处于不活跃的状态,截至目前仅有5次更新,上一次更新依然是4年前,通过一些早期帖子,作者Doron Somech也同样不认为该组件可以在生产环境下使用[5](??),所以事实上可能不太适合作为专业团队的技术选型。
目前比较详细的介绍来自杰哥很忙[6],且优秀的杰哥对fork了该组件的代码[7],并开发了许多功能,由于主干仓库已经尘封太久了,开发者有兴趣可以参详参详。
使用时,我们可通过Nuget下载由NetMQ官方发布的组件,不过,似乎下载量有点惨淡,那么,此处就不再赘述了。。。。
非对称密钥算法-Rsa加密
对于文本来说,使用Rsa这种非对称算法族进行加密是一种非常常见的选择,RSA是由罗纳德·李维斯特[8](Ron Rivest)、阿迪·萨莫尔[9](Adi Shamir)和伦纳德·阿德曼[10](Leonard Adleman)在1977年一起提出的,当时他们三人都在麻省理工学院[11]工作。RSA 就是他们三人姓氏开头字母拼在一起组成的。
RSA算法的核心是极大整数做因数分解,换言之,对一极大整数做因式分解越困难,RSA算法越可靠。目前传统计算机只能破解较为简单的RSA密钥,如果使用的密钥长度足够长,理论上用RSA加密的信息也很难以被破解。在RSA算法中,密钥由私钥和公钥组成。由私钥负责对内容进行解密,并用公钥进行加密。分配公钥的过程必须足够安全,若被中间人攻击,则可能导致公钥失效。
影响RSA密钥安全性的要素首先是其密钥长度,目前推荐的RSA算法公钥长度为2048位。其次是RSA密钥的填充模式,共有三种填充模式,RSA_PKCS1_PADDING, RSA_PKCS1_OAEP_PADDING, RSA_NO_PADDING。填充技术实现的不好,RSA也不会安全,应尽量选择最安全的填充模式,例如RSA_PKCS1_PADDING。
原因如下[12]:
1.RSA加密是确定的,即给定一个密钥,特定明文总会映射到特定的密文。攻击者可以根据密文中统计信息获取明文的一些信息。
2.填充技术如果比较弱,那么较小的明文和小型公开指数e将易于受到攻击。
3.RSA有个特性叫做延展性,如果攻击者可以将一种密文转换为另一种密文,这种新密文会导致对明文的转换变得可知,这种特性并没有解密明文,而是以一种可预测的方式操纵了明文,比如:银行交易系统中,攻击者根据新密文,直接去修改原密文中金额的数据,可以在用户和接受方无法感知的情况下进行修改。
在RSA算法中提供了以下功能提供[13]:
- 密钥对生成:生成随机私钥(通常大小为 1024-4096 位)和相应的公钥。
- 加密:使用公钥加密秘密消息(范围为 [0...key_length] 的整数),并使用秘密密钥将其解密。
- 数字签名:签署消息(使用私钥)并验证消息签名(使用公钥)。
- 密钥交换:安全地传输一个秘密密钥,用于以后的加密通信。
RSA 可以使用不同长度的密钥:1024、2048、3072、4096、8129、16384 甚至更多位的密钥。3072 位及以上的密钥长度被认为是安全的。更长的密钥提供更高的安全性,但消耗更多的计算时间,因此需要在安全性和速度之间进行权衡。很长的 RSA 密钥(例如 50000 位或 65536 位)对于实际使用来说可能太慢,例如密钥生成可能需要几分钟到几个小时。
网上也有基于RSA进行NetMQ进行消息加密的示例[14],可供参考。其核心流程为,在进行消息发送时,使用RSA公钥进行加密,
- MsgObject sendmsg = EventQueue.Dequeue ( ) ;
- sendmsg.Content = RSAEncryption.RSAEncrypt(sendmsg.Content);
- sendmsg.MachineName= msg.MachineName;
- SendMessageQueue.Enqueue(sendmsg) ;
并在客户端接收到消息后,对正文进行RSA解密,解密代码略。
使用对称密钥加密算法-Ecc加密算法进行消息加密
RSA算法虽好,但由于私钥由客户端管理,公钥由服务端管理,且RSA必须密钥位数足够长才安全,例如2048位,使用这么长的密钥进行加密时间开销也令人吃不消的,有没有一种更简单、快速的算法来实现呢?
使用AES算法?
我们或许会想到AES算法,例如AES256算法这种“对称密钥加密算法[15]”。在“对称密钥加密算法”中,加密和解密使用秘密密钥或密码短语(从中派生出密钥)。
该秘密密钥用于加密和解密数据,通常是128位或256位,并被称为“加密密钥”。有时它以十六进制或 base64 编码的整数形式给出,或者通过密码到密钥派生方案派生,当输入数据被加密时,它被转换为加密的密文,当密文被解密时,它被转换回原始输入数据。
通常,对称加密过程使用一系列步骤,涉及不同的加密算法:
密码到密钥派生算法(如 Scrypt 或 Argon2):允许使用密码而不是密钥,并使密码破解变得困难而缓慢。
块到流密码转换算法(块密码模式如CBC或CTR )+消息填充算法如PKCS7 (在某些模式下):允许使用块密码算法(如AES)加密任意大小的数据。
块密码算法(如AES ):使用密钥安全地加密固定长度的数据块。
消息认证算法(如HMAC ):检查解密后得到的结果是否与加密前的原始消息匹配。
NETMQ的原生解决方案?
不过上述AES加密算法实质上也需要开发者手工处理消息体,存在的内存开销和时间可能对于用户来说依然无法接受,或许最好的办法依然是基于NETMQ框架来入手看看是否有什么“原生”的解决方案。
所幸ZeroMQ在设计之初就已经将安全作为其认为非常重要的一个方面,在这篇博客[16]中,ZeroMQ提到了其对于安全层的目标,包括:
- 它使用起来必须非常简单,而且不可能出错。复杂性是密码学的第一大风险和第一大漏洞。每一个额外的选项都是一种出错的方式,最终导致一个不安全的系统。
- 对于实际工作,它必须足够快。如果安全性使系统变得太慢而无法使用,人们就会将其关闭,因为今天能够工作的务实需求胜过明天被入侵的风险。
- 它必须基于标准化协议,以便任何团队都可以重新实施、独立验证并在软件堆栈之外进行改进。
- 等等。
并从2013年起,在ZeroMQ版本(4.0.0)中就已经引入了安全架构设计,包括:
- 一种新的有线协议ZMTP 3.0[17],为所有 ZeroMQ 连接添加了安全握手。
- 一种新的安全协议CurveZMQ[18],它通过 TCP 连接在两个 ZeroMQ 对等点之间实现“完美的前向安全”。我将在下面解释 CurveZMQ。
- ZMTP 的一组安全机制:NULL、PLAIN 和 CURVE,每个机制都由它们自己的 RFC 描述。NULL 本质上是我们之前所拥有的。PLAIN 允许简单的用户名和密码验证。CURVE 实现了 CurveZMQ 协议。、
- 等等。
在ZeroMQ中集成的椭球曲线算法为Curve25519[19] ,目前,在我们所使用的NetMQ中也同样集成了该算法。在搞清楚原理后,我们再来使用该算法,发现一切就变得非常简单明了了。
- var serverPair = NetMQ.NetMQCertificate.CreateFromSecretKey(UTF8Encoding.UTF8.GetBytes(”这里是密钥“));;
- using var server = new PublishSocket();
- server.Options.CurveServer = true;
- server.Options.CurveCertificate = serverPair;
- server.Bind($"tcp://127.0.0.1:55367");
- using (var server = new SubscriberSocket())
- {
- var cert = NetMQ.NetMQCertificate.CreateFromSecretKey(UTF8Encoding.UTF8.GetBytes(”这里是密钥“));
- var curveServerCertificate = serverPair;
- var clientCertificate = new NetMQCertificate(); ---这里是客户端密钥,
- server.Options.CurveServerCertificate = curveServerCertificate; ---这里是使用服务端密钥
- server.Options.CurveCertificate = clientCertificate; ---这里是客户端密钥
- }
结语
本文对NetMQ进行了简单的概述,包括其常见模式和加密传输机制,开发者若有兴趣,可通过NetMQ官网获得更多学习资料。如果开发者加密算法感兴趣,还可以通过这个网站(https://cryptobook.nakov.com)读到许多有关加密的基础知识。
References
[1] somdoron: https://github.com/somdoron
[2] 微服务架构的基础设施: https://zguide.zeromq.org/docs/chapter8/
[3] 类似于MapReduce的数据处理器: https://github.com/sdiehl/kaylee
[4] NetMQ.Security: https://github.com/NetMQ/NetMQ.Security
[5] 同样不认为该组件可以在生产环境下使用: https://groups.google.com/g/netmq-dev/c/3tcsLvxUWgc
[6] 杰哥很忙: https://www.cnblogs.com/Jack-Blog/p/9015783.html
[7] 该组件的代码: https://github.com/GuojieLin/NetMQ.Security
[8] 罗纳德·李维斯特: https://zh.wikipedia.org/wiki/罗纳德·李维斯特
[9] 阿迪·萨莫尔: https://zh.wikipedia.org/wiki/阿迪·萨莫尔
[10] 伦纳德·阿德曼: https://zh.wikipedia.org/wiki/伦纳德·阿德曼
[11] 麻省理工学院: https://zh.wikipedia.org/wiki/麻省理工学院
[12] 原因如下: https://blog.csdn.net/makenothing/article/details/88429511
[13] 以下功能提供: https://cryptobook.nakov.com/asymmetric-key-ciphers/the-rsa-cryptosystem-concepts
[14] 消息加密的示例: https://blog.actorsfit.in/a?ID=01400-85ae6267-6c93-41e3-b06b-5d9792a422ba/
[15] 对称密钥加密算法: https://cryptobook.nakov.com/symmetric-key-ciphers
[16] 这篇博客: https://jaxenter.com/using-zeromq-security-part-1-119346.html
[17] ZMTP 3.0: http://zmtp.org/
[18] CurveZMQ: http://curvezmq.org/
[19] Curve25519: http://cr.yp.to/ecdh.html