第3章 超级账本的系统架构
区块链的业务需求多种多样,一些要求在快速达成网络共识及快速确认区块后,才可以将区块加入区块链中。有一些可以接受相对缓慢的处理时间,以换取较低级别的信任。各行各业在扩展性、可信度、合法性、工作流复杂度以及安全性等方面的需求和用途都不尽相同。我们先来看一下在企业级区块链系统中常见的模块构成,如图3-1所示。
图3-1 企业级区块链系统的常用功能
从图3-1中可以看到一些常用的功能模块有:应用程序、成员管理、智能合约、账本、共识机制、事件机制、系统管理等。纵轴代表用户或者开发者更关心的内容,越往上代表用户更关注,比如应用程序和钱包等,越靠下是开发者更关注的模块,比如事件机制。而横轴则是从时间的维度来看的,左边是一开始关注的功能,直到完成所有的功能。
Hyperledger Fabric 1.0是一种通用的区块链技术,其设计目标是利用一些成熟的技术实现分布式账本技术(Distributed Ledger Technology, DLT)。超级账本采用模块化架构设计,复用通用的功能模块和接口。模块化的方法带来了可扩展性、灵活性等优势,会减少模块修改、升级带来的影响,能很好地利用微服务实现区块链应用系统的开发和部署。Hyperledger Fabric 1.0设计有几个特点:
1)模块插件化:很多的功能模块(如CA模块、共识算法、状态数据库存储、ESCC、VSCC、BCCSP等)都是可插拔的,系统提供了通用的接口和默认的实现,这满足了大多数的业务需求。这些模块也可以根据需求进行扩展,集成到系统中。
2)充分利用容器技术:不仅节点使用容器作为运行环境,链码也默认运行在安全的容器中。应用程序或者外部系统不能直接操作链码,必须通过背书节点提供的接口转发给链码来执行。容器给链码运行提供的是安全沙箱环境,把链码的环境和背书节点的环境隔离开,链码存在安全问题也不会影响到背书节点。
3)可扩展性:Hyperledger Fabric 1.0在0.6版本的基础上,对Peer节点的角色进行了拆分,有背书节点(Endorser)、排序服务节点(Orderer)、记账节点(Committer)等,不同角色的节点有不同的功能。节点可以加入到不同的通道(Channel)中,链码可以运行在不同的节点上,这样可以更好地提升并行执行的效率和吞吐量。
4)安全性:Hyperledger Fabric 1.0提供的是授权访问的区块链网络,节点共同维护成员信息,MSP(Membership Service Provider)模块验证、授权了最终用户后才能使用区块链网络的功能。多链和多通道的设计容易实现数据隔离,也提供了应用程序和链码之间的安全通道,实现了隐私保护。
3.1 系统逻辑架构
图3-2所示为Hyperledger Fabric 1.0设计的系统逻辑架构图。
图3-2 Hyperledger Fabric 1.0的系统逻辑架构图
图3-2所示的系统逻辑架构图是从不同角度来划分的,上层从应用程序的角度,提供了标准的gRPC接口,在API的基础之上封装了不同语言的SDK,包括Golang、Node.js、Java、Python等,开发人员可以利用SDK开发基于区块链的应用。区块链强一致性要求,各个节点之间达成共识需要较长的执行时间,也是采用异步通信的模式进行开发的,事件模块可以在触发区块事件或者链码事件的时候执行预先定义的回调函数。下面分别从应用程序和底层的角度分析应该关注的几个要素。
1.应用程序角度
(1)身份管理
用户注册和登录系统后,获取到用户注册证书(ECert),其他所有的操作都需要与用户证书关联的私钥进行签名,消息接收方首先会进行签名验证,才进行后续的消息处理。网络节点同样会用到颁发的证书,比如系统启动和网络节点管理等都会对用户身份进行认证和授权。
(2)账本管理
授权的用户是可以查询账本数据(ledger)的,这可以通过多种方式查询,包括根据区块号查询区块、根据区块哈希查询区块、根据交易号查询区块、根据交易号查询交易,还可以根据通道名称获取查询到的区块链信息。
(3)交易管理
账本数据只能通过交易执行才能更新,应用程序通过交易管理提交交易提案(Proposal)并获取到交易背书(Endorsement)以后,再给排序服务节点提交交易,然后打包生成区块。SDK提供接口,利用用户证书本地生成交易号,背书节点和记账节点都会校验是否存在重复交易。
(4)智能合约
实现“可编程的账本”(Programmable Ledger),通过链码执行提交的交易,实现基于区块链的智能合约业务逻辑。只有智能合约才能更新账本数据,其他模块是不能直接修改状态数据(World State)的。
2.底层角度
下面的内容是从Hyperledger Fabric 1.0底层的角度来看,如何实现分布式账本技术,给应用程序提供区块链服务。
(1)成员管理
MSP(Membership Service Provider)对成员管理进行了抽象,每个MSP都会建立一套根信任证书(Root of Trust Certificate)体系,利用PKI(Public Key Infrastructure)对成员身份进行认证,验证成员用户提交请求的签名。结合Fabric-CA或者第三方CA系统,提供成员注册功能,并对成员身份证书进行管理,例如证书新增和撤销。注册的证书分为注册证书(ECert)、交易证书(TCert)和TLS证书(TLS Cert),它们分别用于用户身份、交易签名和TLS传输。
(2)共识服务
在分布式节点环境下,要实现同一个链上不同节点区块的一致性,同时要确保区块里的交易有效和有序。共识机制由3个阶段完成:客户端向背书节点提交提案进行签名背书,客户端将背书后的交易提交给排序服务节点进行交易排序,生成区块和排序服务,之后广播给记账节点验证交易后写入本地账本。网络节点的P2P协议采用的是基于Gossip的数据分发,以同一组织为传播范围来同步数据,提升网络传输的效率。
(3)链码服务
智能合约的实现依赖于安全的执行环境,确保安全的执行过程和用户数据的隔离。Hyperledger Fabric采用Docker管理普通的链码,提供安全的沙箱环境和镜像文件仓库。其好处是容易支持多种语言的链码,扩展性很好。Docker的方案也有自身的问题,比如对环境要求较高,占用资源较多,性能不高等,实现过程中也存在与Kubernetes、Rancher等平台的兼容性问题。
(4)安全和密码服务
安全问题是企业级区块链关心的问题,尤其在关注国家安全的项目中。其中底层的密码学支持尤其重要,Hyperledger Fabric 1.0专门定义了一个BCCSP(BlockChain Cryptographic Service Provider),使其实现密钥生成、哈希运算、签名验签、加密解密等基础功能。BCCSP是一个抽象的接口,默认是软实现的国标算法,目前社区和较多的厂家都在实现国密的算法和HSM(Hardware Security Module)。
Hyperledger Fabric 1.0在架构上的设计具有很好的可扩展性,目前是众多可见的区块链技术中最为活跃的,值得区块链技术爱好者深入研究。
3.2 网络节点架构
节点是区块链的通信主体,是一个逻辑概念。多个不同类型的节点可以运行在同一物理服务器上。有多种类型的节点:客户端、Peer节点、排序服务节点和CA节点。图3-3所示为网络节点架构图。
图3-3 网络节点架构图
接下来详细地解释图3-3所示的不同节点的类型。
1.客户端节点
客户端或者应用程序代表由最终用户操作的实体,它必须连接到某一个Peer节点或者排序服务节点上与区块链网络进行通信。客户端向背书节点(Endorser)提交交易提案(Transaction Proposal),当收集到足够背书后,向排序服务广播交易,进行排序,生成区块。
2. Peer节点
所有的Peer节点都是记账节点(Committer),负责验证从排序服务节点区块里的交易,维护状态数据和账本的副本。部分节点会执行交易并对结果进行签名背书,充当背书节点的角色。背书节点是动态的角色,是与具体链码绑定的。每个链码在实例化的时候都会设置背书策略,指定哪些节点对交易背书后才是有效的。也只有在应用程序向它发起交易背书请求的时候才是背书节点,其他时候就是普通的记账节点,只负责验证交易并记账。
图3-2所示的Peer节点还有一种角色是主节点(Leader Peer),代表的是和排序服务节点通信的节点,负责从排序服务节点处获取最新的区块并在组织内部同步。可以强制设置为主节点,也可以动态选举产生。
在图3-2中还可以看到,有的节点同时是背书节点和记账节点,也可以同时是背书节点、主节点和记账节点,也可以只是记账节点。在后面的章节中,有的地方会用记账节点代表普通的Peer节点。
3.排序服务节点
排序服务节点(Ordering Service Node或者Orderer)接收包含背书签名的交易,对未打包的交易进行排序生成区块,广播给Peer节点。排序服务提供的是原子广播(Atomic Broadcast),保证同一个链上的节点接收到相同的消息,并且有相同的逻辑顺序。
排序服务的多通道(MultiChannel)实现了多链的数据隔离,保证只有同一个链的Peer节点才能访问链上的数据,保护用户数据的隐私。
排序服务可以采用集中式服务,也可以采用分布式协议。可以实现不同级别的容错处理,目前正式发布的版本只支持Apache Kafka集群,提供交易排序的功能,只实现CFT(Crash Fault Tolerence,崩溃故障容错),不支持BFT(Byzantine Fault Tolerance,拜占庭容错)。
4. CA节点
CA节点是Hyperledger Fabric 1.0的证书颁发机构(Certificate Authority),由服务器和客户端组件组成。CA节点接收客户端的注册申请,返回注册密码用于用户登录,以便获取身份证书。在区块链网络上所有的操作都会验证用户的身份。CA节点是可选的,可以用其他成熟的第三方CA颁发证书。
3.3 典型交易流程
图3-4所示为Hyperledger Fabric 1.0典型的交易流程图。
图3-4 交易流程总图
从上一节的网络节点架构中,我们已经了解到基于Hyperledger Fabric 1.0的区块链应用中涉及几个节点角色:应用程序、背书节点、排序服务节点和主节点。在图3-4中,假定各节点已经提前颁发好证书,且已正常启动,并加入已经创建好的通道。后面的步骤介绍在已经实例化了的链码通道上从发起一个调用交易到最终记账的全过程。
3.3.1 创建交易提案并发送给背书节点
使用应用程序构造交易提案,SignedProposal的结构如下所示:
SignedProposal: { ProposalBytes(Proposal): { Header: { ChannelHeader: { Type: "HeaderType_ENDORSER_TRANSACTION", TxId: TxId, Timestamp: Timestamp, ChannelId: ChannelId, Extension(ChaincodeHeaderExtension): { PayloadVisibility: PayloadVisibility, ChaincodeId: { Path: Path, Name: Name, Version: Version } }, Epoch: Epoch }, SignatureHeader: { Creator: Creator, Nonce: Nonce } }, Payload: { ChaincodeProposalPayload: { Input(ChaincodeInvocationSpec): { ChaincodeSpec: { Type: Type, ChaincodeId: { Name: Name }, Input(ChaincodeInput): { Args: [] } } }, TransientMap: TransientMap } } }, Signature: Signature }
我们来看看上面的结构,SignedProposal是封装了Proposal的结构,添加了调用者的签名信息。背书节点会根据签名信息验证其是否是一个有效的消息。Proposal由两个部分组成:消息头和消息结构。消息结构详细的解释参考后面的章节。这里简单讲一下消息头。
消息头(Header)也包含两项内容。
1)通道头(ChannelHeader):通道头包含了与通道和链码调用相关的信息,比如在哪个通道上调用哪个版本的链码。TxId是应用程序本地生成的交易号,跟调用者的身份证书相关,可以避免交易号的冲突,背书节点和记账节点都会校验是否存在重复交易。
2)签名头(SignatureHeader):签名头包含了调用者的身份证书和一个随机数,用于消息的有效性校验。
应用程序构造好交易提案请求后,选择背书节点执行并进行背书签名。背书节点是链码背书策略里指定的节点。有一些背书节点是离线的,其他的背书节点可以拒绝对交易进行背书,也可以不背书。应用程序可以尝试使用其他可用的背书节点来满足策略。应用程序以何种顺序给背书节点发送背书请求是没有关系的,正常情况下背书节点执行后的结果是一致的,只有背书节点对结果的签名不一样。
3.3.2 背书节点模拟交易并生成背书签名
背书节点在收到交易提案后会进行一些验证,包括:
□交易提案的格式是否正确;
□交易是否提交过(重复攻击保护);
□交易签名有效(通过MSP);
□交易提案的提交者在当前通道上是否已授权有写权限。
验证通过后,背书节点会根据当前账本数据模拟执行链码中的业务逻辑并生成读写集(RwSet),其中包含响应值、读写集等。在模拟执行时账本数据不会更新。而后背书节点对这些读写集进行签名成为提案响应(Proposal Response),然后返回给应用程序。ProposalResponse的结构如下:
ProposalResponse: { Version: Version, Timestamp: Timestamp, Response: { Status: Status, Message: Message, Payload: Payload }, Payload(ProposalResponsePayload): { ProposalHash: ProposalHash, Extension(ChaincodeAction): { Results(TxRwSet): { NsRwSets(NsRwSet): [ NameSpace: NameSpace, KvRwSet: { Reads(KVRead): [ Key: Key, Version: { BlockNum: BlockNum, TxNum: TxNum } ], RangeQueriesInfo(RangeQueryInfo): [ StartKey: StartKey, EndKey: EndKey, ItrExhausted: ItrExhausted, ReadsInfo: ReadsInfo ], Writes(KVWrite): [ Key: Key, IsDelete: IsDelete, Value: Value ] } ] }, Events(ChaincodeEvent): { ChaincodeId: ChaincodeId, TxId: TxId, EventName: EventName, Payload: Payload } Response: { Status: Status, Message: Message, Payload: Payload }, ChaincodeId: ChaincodeId } }, Endorsement: { Endorser: Endorser, Signature: Signature } }
返回的ProposalResponse中包含了读写集、背书节点签名以及通道名称等信息,更多字段的详细解释参考3.4节。
背书节点接收消息后执行的详细过程请参考第9章的相关内容。
3.3.3 收集交易的背书
应用程序收到ProposalResponse后会对背书节点签名进行验证,所有节点接收到任何消息后都是需要先验证消息合法性的。如果链码只进行账本查询,应用程序会检查查询响应,但不会将交易提交给排序服务节点。如果链码对账本进行Invoke操作,则须提交交易给排序服务进行账本更新,应用程序会在提交交易前判断背书策略是否满足。如果应用程序没有收集到足够的背书就提交交易了,记账节点在提交验证阶段会发现交易不能满足背书策略,标记为无效交易。
如何选择背书节点呢?目前fabric-sdk-go默认的实现是把配置文件选项channels. mychannel.peers(其中的mychannel需要替换成实际的通道名称)里的节点全部添加为背书节点,需要等待所有背书节点的背书签名。应用程序等待每个背书节点执行的超时时间是通过配置文件选项client.peer.timeout.connection设置的,配置文件的示例给出的是3秒,根据实际情况调整,如果没有设置就是5秒的默认值。
3.3.4 构造交易请求并发送给排序服务节点
应用程序接收到所有的背书节点签名后,根据背书签名调用SDK生成交易,广播给排序服务节点。生成交易的过程比较简单,确认所有的背书节点的执行结果完全一致,再将交易提案、提案响应和背书签名打包生成交易。交易的结构如下:
Envelope: { Payload: { Header: { ChannelHeader: { Type: "HeaderType_ENDORSER_TRANSACTION", TxId: TxId, Timestamp: Timestamp, ChannelId: ChannelId, Extension(ChaincodeHeaderExtension): { PayloadVisibility: PayloadVisibility, ChaincodeId: { Path: Path, Name: Name, Version: Version } }, Epoch: Epoch }, SignatureHeader: { Creator: Creator, Nonce: Nonce } }, Data(Transaction): { TransactionAction: [ Header(SignatureHeader): { Creator: Creator, Nonce: Nonce }, Payload(ChaincodeActionPayload): { ChaincodeProposalPayload: { Input(ChaincodeInvocationSpec): { ChaincodeSpec: { Type: Type, ChaincodeId: { Name: Name }, Input(ChaincodeInput): { Args: [] } } }, TransientMap: nil }, Action(ChaincodeEndorsedAction): { Payload(ProposalResponsePayload): { ProposalHash: ProposalHash, Extension(ChaincodeAction): { Results(TxRwSet): { NsRwSets(NsRwSet): [ NameSpace: NameSpace, KvRwSet: { Reads(KVRead): [ Key: Key, Version: { BlockNum: BlockNum, TxNum: TxNum } ], RangeQueriesInfo(RangeQueryInfo): [ StartKey: StartKey, EndKey: EndKey, ItrExhausted: ItrExhausted, ReadsInfo: ReadsInfo ], Writes(KVWrite): [ Key: Key, IsDelete: IsDelete, Value: Value ] } ] }, Events(ChaincodeEvent): { ChaincodeId: ChaincodeId, TxId: TxId, EventName: EventName, Payload: Payload } Response: { Status: Status, Message: Message, Payload: Payload }, ChaincodeId: ChaincodeId } }, Endorsement: [ Endorser: Endorser, Signature: Signature ] } } ] } }, Signature: Signature }
我们来看交易信封的几个对应关系:
□Envelope.Payload.Header同交易提案SignedProposal.Proposal.Header;
□Envelope.Payload.Data.TransactionAction.Header是交易提案的提交者的身份信息,同SignedProposal.Proposal.Header.SignatureHeader和Envelope.Payload.Header. SignatureHeader是冗余的;
□Envelope.Payload.Data.TransactionAction.Payload.ChaincodeProposalPayload同交易提案的SignedProposal.Proposal.Payload.ChaincodeProposalPayload,唯一不同的是,TransientMap强制设置为nil,目的是避免在区块中出现一些敏感信息;
□Envelope.Payload.Data.TransactionAction.Payload.Action.Payload结构,其实和Proposal Response.Payload结构完全一样;
□Envelope.Payload.Data.TransactionAction.Payload.Action.Endorsement变成了数组,代表多个背书节点的背书签名。
整个信封Envelope的Signature是交易提交者对整个Envelope.Payload的签名。应用程序可以把生成的交易信封内容发送给任意选择的几个排序服务节点。
3.3.5 排序服务节点以对交易进行排序并生成区块
排序服务不读取交易的内容,如果在生成交易信封内容的时候伪造了交易模拟执行的结果,排序服务节点也不会发现,但会在最终的交易验证阶段校验出来并标记为无效交易。排序服务要做得很简单,先是接收网络中所有通道发出的交易信息,读取交易信封的Envelope.Payload.Header.ChannelHeader.ChannelId以获取通道名称,按各个通道上交易的接收时间顺序对交易信息进行排序,生成区块,详细的流程请参考第6章。
3.3.6 排序服务节点以广播给组织的主节点
排序服务节点生成区块以后会广播给通道上不同组织的主节点,详细的流程请参考第4章。
3.3.7 记账节点验证区块内容并写入区块
背书节点是动态角色,只要参与交易的背书就是背书节点,哪些交易选择哪些节点作为背书节点是由应用程序选择的,这需要满足背书策略才能生效。所有的背书节点都属于记账节点。所有的Peer节点都是记账节点,记录的是节点已加入通道的账本数据。记账节点接收到的是排序服务节点生成的区块,验证区块交易的有效性,提交到本地账本后再产生一个生成区块的事件,监听区块事件的应用程序可以进行后续的处理。如果接收到的区块是配置区块,则会更新缓存的配置信息。记账节点的处理流程如图3-5所示。
图3-5 记账节点的流程图
1. 交易数据的验证
区块数据的验证是以交易验证为单位的,每次对区块进行验证时都会生成一个交易号的位图TxValidationFlags,它记录每个交易号的交易验证状态,只有状态为TxValidationCode_VALID才是有效的。位图也会写入到区块的元数据BlockMetadataIndex_TRANSACTIONS_FILTER中。交易验证的时候会检查以下内容:
□是否为合法的交易:交易格式是否正确,是否有合法的签名,交易内容是否被篡改;
□记账节点是否加入了这个通道。
基本的验证通过以后会提交给VSCC进行背书策略的验证。
2. 记账节点与VSCC
链码的交易是隔离的,每个交易的模拟执行结果读写集TxRwSet都包含了交易所属的链码。为了避免错误地更新链码交易数据,在交易提交给系统链码VSCC验证交易内容之前,还会对链码进行校验。下面这些交易都是非法的:
□链码的名称或者版本为空;
□交易消息头里的链码名称Envelope.Payload.Header.ChannelHeader.Extension.ChaincodeId. Name和交易数据里的链码名称Envelope.Payload.Data.TransactionAction.Payload. ChaincodeProposalPayload.Input.ChaincodeSpec.ChaincodeId.Name不一致;
□链码更新当前链码数据时,生成读写集的链码版本不是LSCC记录的最新版本;
□应用程序链码更新了LSCC(生命周期管理系统链码)的数据;
□应用程序链码更新了不可被外部调用的系统链码的数据;
□应用程序链码更新了不可被其他链码调用的系统链码的数据;
□调用了不可被外部调用的系统链码。
更多系统链码的介绍,请参考9.3节。
3. 基于状态数据的验证和MVCC检查
交易通过VSCC检查以后,就进入记账流程。kvledger还会对读写集TxRwSet进行MVCC(Multi-Version Concurrency Control)检查。
kvledger实现的是基于键值对(key-value)的状态数据模型。对状态数据的键有3种操作:
□读状态数据;
□写状态数据;
□删除状态数据。
对状态数据的读操作有两种形式:
□基于单一键的读取;
□基于键范围的读取。
MVCC检查只对读数据进行校验,基本逻辑是对模拟执行时状态数据的版本和提交交易时状态数据的版本进行比较。如果数据版本发生变化或者某个键的范围数据发生变化,就说明这段时间之内有别的交易改变了状态数据,当前交易基于原有状态的处理就是有问题的。由于交易提交是并行的,所以在交易未打包生成区块之前,并不能确定最终的执行顺序。如果交易执行的顺序存在依赖,在MVCC检查的时候就会出现依赖的状态发生了变化,实际上是数据出现了冲突。图3-6所示为基于状态的数据验证的流程图。
图3-6 基于状态的数据验证的流程图
写集合本身包含了写和删除的数据,有一个状态位标识是否删除数据。为了提升效率,状态数据库的提交是批处理的,整个区块交易的状态数据同时提交,这也保证了整个区块的状态数据要么都提交成功,要么都提交失败。这时只会出现记录的账本数据和状态数据库不一致,不会出现区块的状态数据不一致的情况。当账本数据和状态数据库不一致时,可以通过状态数据库的检查点来标记。
4. 无效交易的处理
伪造的交易会导致无效交易,正常的交易也可能出现无效交易。MVCC检查的是背书节点在模拟执行的时候,环境是否和记账节点提交交易时的环境一致,这里的环境是指状态数据库里数据的三元组(key、value、version)是否完全一致。如果正常提交的交易在这个过程中涉及的数据发生了变化,那么也会出现检查失败从而导致无效交易。在这种情况下,需要在上层的应用程序有一些补偿措施,比如调整交易打包的配置,重新提交失败的交易等。
在目前版本的实现中,无效交易也会保留在区块中,可以通过区块记录的元数据确定哪些是无效交易。无效交易的读写集不会提交到状态数据库中,不会导致状态数据库发生变化,只是会占用区块的大小,占用记账节点的硬盘空间。后续的版本会实现账本的精简,过滤掉无效交易。
3.3.8 在组织内部同步最新的区块
主节点在组织内部同步区块的过程详见第4章的相关内容。
3.4 消息协议结构
3.4.1 信封消息结构
信封消息是认证内容中最基本的单元。它由一个消息负载(Payload)和一个签名(Signature)组成。
//信封包含一个带有签名的负载,以便认证该消息 message Envelope { //编组的负载 bytes payload = 1; //负载头中指定创建者签名 bytes signature = 2; } //负载是消息内容(允许签名) message Payload { //负载头部,提供身份验证并防止重放 Header header = 1; //数据,其编码由头的类型定义 bytes data = 2; }
负载包含:
1)消息头部。头部带有类型,描述负载的性质以及如何解组数据字段。此外,头部还包含创建者的信息和随机数,以及用来标识时间逻辑窗口的时期信息。只有在两个条件都成立的情况下,Peer节点才能接受一个信封。
① 消息中指定的时期信息是当前窗口期;
② 该负载在该周期内只看到一次(即没有重放)。
2)数据字段的类型由头部指定。头部消息的组织方式如下所示:
message Header { bytes channel_header = 1; bytes signature_header = 2; } //通道头是一个通用的预防重放和身份标识的消息,它包含在一个被签名的负载之中 message ChannelHeader { int32 type = 1; //头类型0-10000由HeaderType保留和定义 //版本指示消息协议版本 int32 version = 2; //时间戳是发件人创建消息的本地时间 google.protobuf.Timestamp timestamp = 3; //该消息绑定通道的标识符 string channel_id = 4; //端到端使用唯一的标识符 // - 由较高层设置,如最终用户或SDK // - 传递给背书节点(将检查唯一性) // - 当消息正确传递时,它将被记账节点检索(也会检查唯一性) // - 存储于账本中 string tx_id = 5; //时期信息基于区块高度而定义,此字段标识时间的逻辑窗口 //只有在两个条件都成立的情况下,对方才接受提案响应 //1. 消息中指定的时期信息是当前时期 //2. 该消息在该时期内只看到一次(即没有重放) uint64 epoch = 6; //根据消息头类型附加的扩展 bytes extension = 7; } enum HeaderType { MESSAGE = 0; //非透明消息 CONFIG = 1; //通道配置 CONFIG_UPDATE = 2; //通道配置更新 ENDORSER_TRANSACTION = 3; // SDK提交背书 ORDERER_TRANSACTION = 4; //排序管理内部使用 DELIVER_SEEK_INFO = 5; //指示Deliver API查找信息 CHAINCODE_PACKAGE = 6; //链码打包安装 } message SignatureHeader { //消息的创建者,链的证书 bytes creator = 1; //只能使用一次的任意数字,可用于检测重放攻击 bytes nonce = 2; }
信封消息结构对于验证负载的签名是必要的。否则,对于大载荷消息,就必须连接所有的载荷再进行签名验证,这往往成本很高。
经过排序后,批量的信封消息交付给记账节点进行验证,通过验证后的数据被记录到账本之中。
3.4.2 配置管理结构
区块链有与之相关的配置,配置设置在创世区块之中,但可能在后续被修改。该配置信息在类型为CONFIGURATION_TRANSACTION的信封消息中编码。配置信息本身就是区块的一个单独交易。配置信息交易没有任何依赖,所以每个配置信息交易必须包含对于链的全量数据,而不是增量数据。使用全量数据更容易引导新的peer或排序节点,也便于未来进行裁剪工作。
CONFIGURATION_TRANSACTION类型的信封消息具有ConfigurationEnvelope类型的负载数据。它定义为:
message ConfigurationEnvelope { repeated SignedConfigurationItem Items = 3; }
配置信息的信封消息有与之关联的序列号和链ID。每次修改配置序列号必须递增,这可以作为防止重放攻击的一个简单机制。配置信息的信封中会嵌入一系列的SignedConfi gurationItems,定义如下:
message SignedConfigurationItem { bytes ConfigurationItem = 1; repeated Envelope Signatures = 2; }
因为SignedConfigurationItem必须支持多个签名,所以它包含一组重复的信封消息。这些消息中每个都有一个类型为CONFI GURATION_ITEM的头部。负载的数据部分在ConfigurationItem中保存,定义为:
message ConfigurationItem { enum ConfigurationType { Policy = 0; Chain = 1; Orderer = 2; Fabric = 3; } bytes ChainID = 1; uint64 LastModified = 2; ConfigurationType Type = 3; string ModificationPolicy = 4; string Key = 5; bytes Value = 6; }
Type提供了配置项的范围和编码信息。LastModified字段设置为上一次配置项被修改时配置信息信封中的序列号。ModificationPolicy指向一个已经命名的策略,用来对将来的签名进行评估,以确定修改是否被授权。Key和Value字段分别用作引用配置项及其内容。
修改配置包含以下内容:
□检索现有配置;
□递增配置信息信封消息中的序列号;
□修改所需的配置项,将每个配置项的LastModified字段设置为递增后的序列号;
□更新SignedConfigurationItem中的签名信息;
□将签名后的信封信息提交给排序节点。
配置管理员将验证:
□所有配置项和信封都指向正确的链;
□添加或修改了哪些配置项;
□有没有现有的配置项被忽略(即提交的数据是全集);
□所有配置更改的LastModification都等于信封消息中的序列号;
□所有配置更改均符合相应的修改策略。
任何有权更改配置项的角色都可以构建新的配置信息交易。修改配置项将更新序列号并产生新的创始区块,这将引导新加入网络的各种节点。
3.4.3 背书流程结构
当Envelope.data中携带与链码相关的消息时,使用ENDORSER_TRANSACTION类型。
获得批准的ENDORSER_TRANSACTION负载流程如下。
首先,客户端向所有相关的背书节点发送提案消息(提案基本上是要进行一些影响账本的动作)。
然后,每个背书节点向客户端发送一个提案响应消息。提案响应包含背书结果的成功/错误码、应答负载和签名。应答负载之中包含提案的哈希值信息,用此信息可以将提案和针对该提案的应答安全地连接起来。
最后,客户端将背书结果写入交易中,签名并发送到排序服务。
在接下来的章节中,我们将详细介绍消息及其流程。
1. 交易提案结构
一个提案消息包含头部(包含描述它的一些元数据,例如类型、调用者的身份、时间、链的ID、加密的随机数)和不透明的负载:
message SignedProposal { //提案 bytes proposal_bytes = 1; //对提案进行签名,该签名将和头部的创建者标识进行验证 bytes signature = 2; } message Proposal { //提案头部 bytes header = 1; //提案负载,具体结构由头部的类型决定 bytes payload = 2; //提案的可选扩展。对于CHAINCODE类型的消息,其内容可能是ChaincodeAction消息 bytes extension = 3; }
一个提案发送给背书节点进行背书。该提案包含:
□头部,可以解组为头部信息;
□负载,由头部的类型决定;
□扩展,由头部的类型决定。
和信封消息结构类似,这种SignedProposal消息结构也是重要的。否则,对于大载荷消息,我们必须连接所有的载荷再进行签名验证,这往往成本很高。
当背书节点收到签名后的提案消息后,它将验证消息中的签名。验证签名需要以下步骤。
1)预验证用户生成签名证书的有效性。一旦SignedProposal.proposal_bytes和Proposal. header都解组成功,就可以认为证书基本是可用的。虽然这种在证书验证前的解组操作可能并不太理想,但是在这个阶段可以过滤掉证书过期的情况。
2)验证证书是否可信(证书是否由受信任的CA签名),并允许交易(能否通过ACL检查)。
3)验证SignedProposal.proposal_bytes上的签名是否有效。
4)检测重放攻击。
以下是当ChainHeader的类型为ENDORSER_TRANSACTION时的消息:
message ChaincodeHeaderExtension { //控制提案的负载在最终交易和账本中的可见程度 bytes payload_visibility = 1; //要定位的链代码ID ChaincodeID chaincode_id = 2; }
ChaincodeHeaderExtension消息用于指定要调用的链码以及应在账本中呈现的内容。理想情况下,payload_visibility是可配置的,支持至少3种主要可见性模式:
□负载所有字节都可见;
□只有负载的哈希值可见;
□任何东西都不可见。
注意,可见性功能可能也会由ESCC设置,此时本字段将会被覆盖。另外本字段也将影响Propos alResponsePayload.proposalHash的内容。
message ChaincodeProposalPayload { //包含调用链码的参数, bytes input = 1; //用于实现某些应用程序级的加密数据 map<string, bytes> TransientMap = 2; }
ChaincodeProposalPa yload消息包含调用链码的参数。TransientMap字段的内容应始终从信封消息中省略掉,并不记录到账本之中。
message ChaincodeAction { //调用链码产生的读/写集 bytes results = 1; //调用链码产生的事件 bytes events = 2; //调用链码的结果 Response response = 3; //含链ID、背书节点在模拟执行提案时设置 //账本节点将验证版本号是否与链码最新版本匹配,含有链ID信息将支持单个交易打开多个链码 ChaincodeID chaincode_id = 4; }
ChaincodeAction消息包含执行链码所产生的动作和事件。results字段包含读取集合,events字段包含由链码执行生成的事件。
2. 提案响应结构
提案响应消息从背书节点返回给提案客户端。背书节点使用该消息表达对于交易提案的处理结果。应答结果可能是成功也可能是失败,另外还会包含动作描述和背书节点的签名。如果足够数量的背书节点同意相同的动作并进行签名,则可以生成负载消息,并发送给排序节点。
message ProposalResponse { //消息协议版本 int32 version = 1; //消息创建时间,由消息发送者定义 google.protobuf.Timestamp timestamp = 2; //某个动作的背书是否成功 Response response = 4; //负载,ProposalResponsePayload字节序列 bytes payload = 5; //提案的具体背书内容,基本上就是背书节点的签名 Endorsement endorsement = 6; } message ProposalResponsePayload { //触发此应答交易提案的哈希值 bytes proposal_hash = 1; //扩展内容,应该解组为特定类型的消息 bytes extension = 2; } message Endorsement { //背书节点身份(例如,证书信息) bytes endorser = 1; //签名,对提案应答负载和背书节点证书这两个内容进行签名 bytes signature = 2; }
ProposalResponsePayload消息是提案响应的负载部分。这个消息是客户端请求和背书节点动作之间的“桥梁”。对于链码来说,它包含一个表示提议的哈希值proposal_hash,以及表示链码状态变化和事件extension字段。
proposal_hash字段将交易提案和提案响应两者对应起来,即为了实现异步系统的记账功能也为了追责和抗抵赖的安全诉求。哈希值通常会覆盖整个提案消息的所有字节中。但是,这样实现就意味着只有获得完整的提案消息才能验证哈希值的正确性。
出于保密原因,使用链码不太可能将提案的负载直接存储在账本中。例如,类型为ENDORSER_TRANSACTION的消息,需要将提案的头部和负载分开进行处理:头部总是进行完整散列的,而负载则可能进行完整散列或对哈希值再进行散列,或者根本不进行散列。
3. 背书交易结构
客户端获得足够的背书后,可以将这些背书组合成一个交易信息。这个交易信息可以设置为负载信息的数据字段。以下是在这种情况下要使用的具体消息:
message Transaction { //负载是一个TransactionAction数组,每个交易需要一个数组来适应多个动作 repeated TransactionAction actions = 1; } message TransactionAction { //提案头部 bytes header = 1; //负载由头部类型决定,它是ChaincodeActionPayload字节序列 bytes payload = 2; }
TransactionAction消息将提案绑定到其动作。它的头部是SignatureHeader消息,它的负载是ChaincodeActionPayload消息。
message ChaincodeActionPayload { // ChaincodeProposalPayload消息的字节序列,内容来自链码原始调用的参数 bytes chaincode_proposal_payload = 1; //应用于账本的动作列表 ChaincodeEndorsedAction action = 2; }
ChaincodeActionPayload消息携带chaincodeProposalPayload和已经通过背书的动作以应用于账本。主要的可见性模式是“full”(整个ChaincodeProposalPayload消息包含在这里)、“hash”(仅包含ChaincodeProposalPayload消息的哈希值)或“nothing”。该字段将用于检查ProposalResponsePayload.proposalHash的一致性。此外,action字段包含应用于账本的动作列表。
message ChaincodeEndorsedAction { //由背书节点签名的ProposalResponsePayload消息字节序列 bytes proposal_response_payload = 1; //提案背书,基本上是背书节点的签名 repeated Endorsement endorsements = 2; }
ChaincodeEndorsedAction消息承载有关具体提案的背书信息。proposalResponsePayload是由背书节点签名的,对于ENDORSER_TRANSACTION类型,ProposalResponsePayload的extenstion字段会带有一个ChaincodeAction。此外,endorsements字段包含提案已经收到的背书信息。
3.5 策略管理和访问控制
在Hyperledger Fabric 1.0中,较多的地方都使用策略进行管理,它是一种权限管理的方法,包括交易背书策略、链码的实例化策略、通道管理策略等。
3.5.1 策略定义及其类型
策略定义了一些规则,验证签名数据是否符合定义的条件,结果为TRUE或者FALSE。策略的定义如下:
type Policy struct { Type int32 //策略的类型 Value []byte //策略的内容 }
策略的类型有两种。
1)SignaturePolicy:在基于验证签名策略的基础上,支持条件AND、O R、NOutOf的任意组合,其中的NOutOf指的是满足m个条件中的n个就表示满足策略(m≥n)。比如OR(Org1.Admin, NOutOf(2, Org2.Member))表示Org1的管理员或者两个Org2的成员签名都满足策略。
2)ImplicitMetaPolicy:隐含的元策略,是在SignaturePolicy之上的策略,支持大多数的组织管理员这种策略,只适用于通道管理策略。
SignaturePolicy实际只有两种类型,SignedBy和NOutOf,其他的,比如AND和OR都会转换成NOutOf类型。其定义如下:
type SignaturePolicy struct { //支持的类型有: // *SignaturePolicy_SignedBy,验证单个签名是否正确 // *SignaturePolicy_NOutOf_,验证是否有n个签名都正确 Type isSignaturePolicy_Type `protobuf_oneof:"Type"` }
ImplicitMetaPolicy是递归策略的定义方法,名称中的Implicit说明规则是由子策略生成的,Meta说明策略依赖其他策略的验证结果。
type ImplicitMetaPolicy struct { SubPolicy string //子策略的名称 Rule ImplicitMetaPolicy_Rule //策略的规则 }
策略的规则支持3种形式:
□ImplicitMetaPolicy_ANY:任意一个子规则成立就满足策略;
□ImplicitMetaPolicy_ALL:全部子规则都成立才满足策略;
□ImplicitMetaPolicy_MAJORITY:大多数的子规则成立就满足策略。
特别说明ImplicitMetaPolicy_MAJORITY需要满足子规则数的计算方法:
threshold = len(subPolicies)/2 + 1
比如一共有3个子策略,需要至少2个子策略成立才能满足策略。如果总共有4个子策略,需要至少3个子策略成立才能满足策略。如果没有子策略,默认是满足的。
策略的内容可以有多种,下面分别来看几种策略:交易背书策略、链码实例化策略和通道管理策略。
3.5.2 交易背书策略
交易背书策略是对交易进行背书的规则,是跟通道和链码相关的,在链码实例化的时候指定。在链码调用的时候,需要从背书节点收集足够的签名背书,只有通过背书策略的交易才是有效的。这是通过应用程序和背书节点之间的交互来完成的,这在前面的交易流程里已经介绍过了。
1. 交易背书策略的验证
背书是由一组签名组成的,每个Peer节点接收到区块时,都能根据交易的内容本地验证背书是否符合背书策略,不需要和其他节点交互。验证交易背书的基本原则是:
1)所有的背书都是有效的,验证消息用有效的证书进行正确的签名;
2)满足背书策略的有效背书数量,转化为NOutOf格式进行比较;
3)背书是期望的背书节点签名的,在背书策略中指定了哪些组织和角色是有效的背书节点。
如何来实现这几个原则的呢?我们先从背书签名的命令行语法开始,背书签名的语法AND和OR都可以转为NOutOf:
□AND(A, B)可以转换为NOutOf(1, A, B);
□OR(A, B)可以转换为NOutOf(2, A, B)。
我们主要来看下NOutOf如何实现,背书策略的定义如下:
type SignaturePolicyEnvelope struct { Version int32 //背书策略版本,默认都是0 Rule *SignaturePolicy //背书策略规则:签名策略 Identities []*common1.MSPPrincipal //背书策略主体:MSP主体签名 }
其中,MSP主体(Principal)是基于MSP的身份标识的,有如下几种类型。
1)MSPPrincipal_ROLE:基于MSP角色的验证方法,目前只有admin和member两种。
2)MSPPrincipal_ORGANIZATION_UNIT:基于部门的验证方法,同一个MSP中的不同部门。
3)MSPPrincipal_IDENTITY:基于某个具体身份证书的验证方法,验证签名是否有效。
MSPPrincipal的定义如下:
type MSPPrincipal struct { PrincipalClassification MSPPrincipal_Classification // MSP的类型 Principal []byte //根据MSP的类型不同,实体有不同的内容 }
根据不同的MSP类型,主体是不同的。
(1)基于MSP角色的验证
当PrincipalClassification是MSPPrincipal_ROLE时,主体存储的内容如下:
type MSPRole struct { // MSP标识符 MspIdentifier string // MSP角色:可选值是MSPRole_MEMBER和MSPRole_ADMIN Role MSPRole_MSPRoleType }
不同角色的验证方法如下:
1)MSPRole_MEMBER:验证是否为同一个MSP的有效签名;
2)MSPRole_ADMIN:验证签名者是否是MSP设置好的admin成员。
(2)基于部门的验证
当PrincipalClassification是MSPPrincipal_ORGANIZATION_UNIT时,主体存储的内容如下:
type OrganizationUnit struct { // MSP标识符 MspIdentifier string //组织部门标识符 OrganizationalUnitIdentifier string //证书标识符:信任证书链和组织部门信息的哈希 CertifiersIdentifier []byte }
验证过程的步骤是:
□验证是否为相同的MSP;
□验证是否是有效的证书;
□验证组织部门信息是否匹配。
(3)基于身份证书的验证
当PrincipalClassification是MSPPrincipal_IDENTITY时,主体存储的内容如下:
type identity struct { //身份标识符,包含MSP标识符和身份编号 id *IdentityIdentifier //身份的数字证书,包含了对公钥的签名 cert *x509.Certificate //身份的公钥 pk bccsp.Key //身份的MSP信息 msp *bccspmsp }
这样验证MSP是否是有效证书就可以了。
2. 命令行的背书策略语法
在命令行里,可以用一种简单的语言,根据主体的布尔表达式来表示策略。主体是用MSP来表示的,用来验证签名者的标识和签名者在MSP里的角色。目前支持两种角色:member和admin。主体的表示方法是MSP.ROLE,其中MSP是MSP的标识,ROLE可以是memeber也可以是admin。这都是有效的主体:Org0.admin表示由MSP标识Org0的任何一个管理员,Org1.memeber表示由MSP标识Org1的任何一个成员。
其语法是:EXPR(E[, E...]),其中EXPR可以是AND也可以是OR, E可以为一个主体,也可以为嵌套的EXPR。比如:
1)AND('Org1.member', 'Org2.member', 'Org3.member')要求3个MSP标识Org1、Org2和Org3,其中每个MSP都有1个成员有1个签名;
2)OR('Org1.member', 'Org2.member')要求2个MSP标识Org1、Org2,其中任何1个成员有1个签名;
3)OR('Org1.member', AND('Org2.member', 'Org3.member'))要求MSP标识Org1的成员有1个签名,或者MSP标识Org2和Org3的成员都有1个签名。
目前在命令行的语法中,背书策略只支持AND和OR两种,并不支持更为复杂的NOutOf。这部分的设计在后续内容中也会有调整。SDK对背书策略都会转换成NOutOf语法,不过不是所有的SDK都支持。比如目前fabric-sdk-go提供的默认接口不支持NOutOf语法,但其内部是支持的,稍加改动很容易就能支持。详细可以参考cauthdsl_builder.go文件。
3. 给链码指定背书策略
背书策略可以在部署的时候用-P参数指定,后面是具体的背书策略。比如:
peer chaincode deploy -C testchainid -n mycc -p github.com/hyperledger/fabric/ examples/chaincode/go/chaincode_example02-c '{"Args":["init", "a", "100", "b", "200"]}’ -P "AND('Org1.member', 'Org2.member')"
这个命令在链testchainid上部署链码mycc,背书策略是AND('Org1.member', 'Org2. member')。如果命令行里没有指定策略,那么默认的背书策略要求MSP标识DEFAULT成员的一个签名。
3.5.3 链码实例化策略
链码实例化策略是用来验证是否有权限进行链码实例化和链码升级的。链码实例化策略是在对链码打包和签名的时候指定的,如果没有指定实例化策略,默认是通道的管理员才能实例化。
type SignedChaincodeDeploymentSpec struct { //链码部署规范 ChaincodeDeploymentSpec []byte //链码的实例化策略,结构同背书策略,在实例化的时候验证 InstantiationPolicy []byte //链码所有者的签名背书列表 OwnerEndorsements []*Endorsement }
链码实例化策略的定义和背书策略完全一样,验证方法也相同,只是用途和用法不一样。链码实例化策略是直接从链码打包中获取的,实例化完成后会将策略存放在链上。在链码实例化和升级的时候会先验证是否符合当前的实例化策略,验证通过才可以更新链码实例化策略。存储在链上的链码信息结构如下所示:
type ChaincodeData struct { //链码名称 Name string //链码版本 Version string //链码的ESCC Escc string //链码的VSCC Vscc string //链码的背书策略 Policy []byte //链码的内容:包含链码的名称、版本、链码源码哈希、链码名称和版本的元数据哈希等内容 //不包含链码源码 Data []byte //链码指纹标识,目前没有使用 Id []byte //链码实例化策略 InstantiationPolicy []byte }
链码信息结构ChaincodeData在链上是按链码的名称索引的。
3.5.4 通道管理策略
通道配置是递归定义的:
type ConfigGroup struct { Version uint64 //配置版本 Groups map[string]*ConfigGroup //子配置 Values map[string]*ConfigValue //配置值 Policies map[string]*ConfigPolicy //配置策略定义 ModPolicy string //配置修改策略的名称 }
其中,配置值ConfigValue定义的是一些配置数据,定义如下:
type ConfigValue struct { Version uint64 //配置版本 Value []byte //配置数据,可以是JSON结构的 ModPolicy string //配置修改策略名称 }
比如在通道配置中区块生成间隔BatchTimeout设置的值是“2s”,局部的格式如下:
"BatchTimeout": { "mod_policy": "Admins", "value": { "timeout": "2s" } }
我们再来看最重要的配置策略的定义:
type ConfigPolicy struct { Version uint64 //配置策略版本 Policy *Policy //配置策略的内容,这在前面已经介绍过 ModPolicy string //配置策略中修改策略的名称 }
从上面的定义中我们可以看到,配置策略是基于SignaturePolicy和ImplicitMetaPolicy的,ModPolicy代表的是修改同级策略用到的策略名称。通道定义了3种配置策略,如
表3-1所示。
表3-1 3种配置策略
1. 通道配置的递归定义
我们来看一个简化的通道配置是如何递归定义的。
Channel: Policies: Readers Writers Admins Groups: Orderer: Policies: Readers Writers Admins Groups: OrdereringOrganization1: Policies: Readers Writers Admins Application: Policies: Readers -----------> Writers Admins Groups: ApplicationOrganization1: Policies: Readers Writers Admins ApplicationOrganization2: Policies: Readers Writers Admins
在上面的配置中,最外层是Channel,它定义了通道的子配置和策略定义。Channel的子配置里面定义了Orderer和Application配置,它们分别是相同的递归定义结构。其中"------->"显示的一行按照层级展开,代表的是/Channel/Application/Writers。
怎么来使用这些配置策略呢?比如在排序服务节点调用Deliver接口的时候会检查这个节点是否满足/Channel/Readers策略。Peer节点同步区块的时候也会检查是否满足/Channel/Application/Readers策略。
更详细的例子参考第6章的相关内容。
2. 通道配置的默认策略
在使用configtxgen工具生成创世区块或者通道配置时,使用的默认策略如表3-2所示。
表3-2 通道配置的默认策略
3.6 本章小结
本章从逻辑结构、节点结构、典型交易流程、消息协议结构、策略管理等几个方面介绍了Hyperledger Fabric 1.0的架构。通过这些内容的介绍,能够基本了解Hyperledger Fabric 1.0的设计原则和思路。