用 Cadence 加 MultiSign 实现 Flowns 合约管理权限分割
在上一篇文章Flow transaction 流程与结构详解 中,我们了解了 Flow 的 Cadence 和交易流程、交易数据结构,也了解了多重签名的经典使用场景 —— 手续费代付。
那么其实 Flow 交易签名的灵活性不仅体现在交易手续费代付上,这样的思路我们可以扩展到更多的复杂权限和场景。
多重签名的特性可以支持更灵活的 Cadence 脚本,为编写复杂 Cadence 权限和逻辑提供的基础,在架构层面打开了脚本执行的权限限制,不仅可以完成权限组合,也可以通过此机制拆分权限。
下面我们结合实际的场景来讲述多重签名的优势和实践。
多签场景
按照手续费代付的逻辑,多签其实也是相同的实现思路,将交易体进行传递,不同的授权人将自己的签名拼装至
payloadSignatures
中, 最后交由 payer 最终封装签名并发送上链。这样的模式和流程可以用到实际的生产环境中。
比如目前 Flowns 的合约部署账户的私钥是有整个 Flowns 根域名下域名创建和管理权限,也就是所谓的超级管理员。
Flowns 的特性之一是支持除
fn
之外多个根域名,但因为多根域名的权限管理比较复杂且不确定未来根域名的合作伙伴的需求,所以在合约编写的阶段就保留了多根于的价格设置和存款 Vault 资源的管理接口。但并没有实现同时支持多个根域名管理者彼此权限隔离的功能。虽然合约的管理权限集中在一个私钥上,但我们仍然可以通过 Cadence 和多重签名的方式来完成根域名权限的拆分和授权。
Flowns && Lilico 根域名铸造权限授权
作为钱包服务提供者,Lilico 有需求发行自己的根域名,利用 Flowns 域名资源支持 NFT & FT 嵌套的能力,让 Flowns 域名成为 Lilico 用户的资产容器,这样能够极大的提升用户在使用钱包给第三方转账时的用户体验。未初始化 Vault 与 Collection 资源的用户无法接收资产问题将会得到解决。
但想要实现这样的功能需要 Flowns 的超级管理员划分权限授权给 Lilico 服务并能够安全的帮助 Lilico 完成根域名下域名的创建和分发。
想要安全的实现此功能,Flowns 和 Lilico 需要共同解决如下问题:
- 安全问题
- 如何在不影响 Flowns 现有根域名
fn
的主要业务的情况下将新的根域名权限授权给 Lilico 或其他的第三方 - 如何进行交易权限的确认,防止中间人攻击,或篡改交易内容,导致 Flowns 核心权限被窃取
- 如果未来出现第二个根域名合作伙伴,如何将他们的权限与现有的合作伙伴隔离,不至于互相影响
- 体验问题
- 如何解决用户手续费不足或者没有足够的 Flow 的情况下让其能够拥有初始化的资源
- 如何在高并发的情况下,保证多笔授权交易的请求不会阻塞甚至失败
接下我们尝试根据 Cadence 和 Flow 的交易特性构建一套能够支持,基于多方服务架构且兼顾公开安全的方式解决上述提到的问题。
安全问题
管理员私钥权限拆分与隔离
不影响 Flowns 现有根域名权限且与外部权限隔离的方法,我们通过 Flowns 服务端进行的脚本白名单进行隔离,也就是说,Flowns 后端服务负责签名交易,且进行 Cadence 脚本的校验。
只允许第三方的账号执行特定的脚本,在多签流程中 Flowns 管理员签名的环节,会进行脚本内容的比对和校验,确保交易执行人和授权人与脚本的权限匹配之后才会签名授权根域名的铸造交易。
也就是说,Flowns 可以将授权的脚本通过接口的形式提供给需要调用的第三方,同时再多重签名的环节进行校验,交易体中的脚本是否是授权可以外部调用的脚本,这样就能实现只有特定的脚本能够被执行,
因为 Flowns 中的根域名的调用在脚本中是以 RootDomainId 的参数形式体现,那么对于 Lilico 的新根域名的发行脚本,Flowns 只需要提供一个能够接受发行基于根域名参数的脚本即可,其他的参数都将写死在 Cadence 脚本中,也作为脚本校验的一部分。
在接收到 Lilico 发来的授权请求之后,Flowns 会先验证脚本交易体中 authorizers 的地址是否和白名单匹配,如果匹配则进行授权操作。
这样的好处在于,交易的脚本匹配,授权人匹配,就可以确定权限所有者是没有问题的,即便是有中间人篡改了交易体内任何的字段,骗过了 Flowns 的验证,依然需要通过 Flow 网络的权限验证,所以整体方案是安全且轻量易于实现的。
权限授予与确认
照上方所述,Flowns 服务端会根据第三方的地址建立授权脚本的关联关系,通过 Cadence 的 hash 验证脚本的权限,加上交易体中的 payload 信息验证请求人的权限,让服务端具备了隔离权限的能力。
我们看 Js 代码
// 授权白名单配置 export const whitelist = { testnet: { '0x**********77cd': ['mintLilicoDomain'], }, mainnet: {}, } // 授权脚本定义 const scripts = { mintLilicoDomain: () => { const script = `Lilico mint script that only allow mint lilico root domain` // 这里的脚本是只允许 lilico 的根域名进行铸造 flowns 域名 return script }, // 或者未来需要授权的其他交易脚本 otherScripts:() => { } } export const getScripts = (key) => { const scriptFunc = scripts[key] const script = scriptFunc ? scriptFunc() : '' return script } // 脚本验证 export const verifyTrx = (script, address) => { let whiteList = whitelist[network] let scriptList = whiteList[address] if (scriptList.len == 0) return false let scriptHash = sha3(script) var flag = false scriptList.forEach((key) => { let script = getScripts(key) let hashStr = sha3(script) if (scriptHash === hashStr) { flag = true } }) return flag }
这里只需要简单的配置和验证就能在确保安全的情况下,把授权的脚本交由第三方调用。
接收 Raw transaction 并拼装与手动签名处理
import * as fcl from '@onflow/fcl' // receive post request const data = req.body const { transaction } = data // init fcl config fclinit() const { script, payerAddress, referenceBlockId, proposalKey, gasLimit, authorizers, payloadSignatures, } = transaction // 验证脚本是否是有权限 let verified = verifyTrx(script, payerAddress) if (verified) { // 手动组装交易体 let trxMsg = { script: script, refBlock: referenceBlockId, arguments: transaction.arguments, proposalKey: { address: proposalKey.address, keyId: proposalKey.keyIndex, sequenceNum: proposalKey.sequenceNumber, }, payer: payerAddress, authorizers, payloadSigs: payloadSignatures, gasLimit: gasLimit, } // RLP encode 将交易体进行编码 let msg = fcl.encodeTransactionPayload(trxMsg) // 对编码后的 msg 进行签名签名实现可见参考 <https://github.com/onflow/fcl-dev-wallet/blob/main/src/crypto.ts#L16> let signature = sign(msg) // 将签名的数据拼装至原交易体的 payloadSignatures 中并返回 transaction.payloadSignatures = transaction.payloadSignatures.concat({ address: flownsAddr, keyIndex: 0, signature, }) // 返回签名后的交易数据,交由第三方进行最后签名并发送 return res.json(transaction)
这里的代码做了几件事情:
- 通过监听 post 请求接受外部第三方的传递而来的 transaction 参数
- 解构 transaction 参数
- 校验授权地址和对应的脚本权限
- 拼装 fcl 支持的 raw transaction 数据
- RLP encode 交易数据(payload)生成 message
- 使用本地私钥调用本地签名函数签名 message
- 拼装签名信息至原交易数据的 payloadSignatures 数组中
- 返回签名后的结果
剩下的流程交由第三方的服务来进行最终的封装签名并发送至 Flow 区块链
多根域名权限隔离管理
我们先看脚本:
import Domains from ${flownsAddr} // 地址会根据不通的网络进行替换做到测试网和主网通用脚本 import Flowns from ${flownsAddr} import NonFungibleToken from ${flowNonFungibleAddr} import FungibleToken from ${flowFungibleAddr} // 参数只保留域名名称 transaction(name: String) { let client: &{Flowns.AdminPrivate} let receiver: Capability<&{NonFungibleToken.Receiver}> // 这里使用到了三方的授权,用户,lilico 和 Flowns prepare(user: AuthAccount, lilico: AuthAccount, flowns: AuthAccount) { let userAcc = getAccount(user.address) // check user balance // 防止用户因余额不够导致无法初始化域名 collection 资源 let userBalRef = userAcc.getCapability(/public/flowTokenBalance).borrow<&{FungibleToken.Balance}>() if userBalRef!.balance < 0.001 { let vaultRef = flowns.borrow<&FungibleToken.Vault>(from: /storage/flowTokenVault) let userReceiverRef = userAcc.getCapability(/public/flowTokenReceiver).borrow<&{FungibleToken.Receiver}>() userReceiverRef!.deposit(from: <- vaultRef!.withdraw(amount: 0.001)) } // init user's domain collection // 如果用户还没有域名资源,则帮助用户初始化 if user.getCapability<&{NonFungibleToken.Receiver}>(Domains.CollectionPublicPath).check() == false { if user.borrow<&Domains.Collection>(from: Domains.CollectionStoragePath) != nil { user.unlink(Domains.CollectionPublicPath) user.link<&Domains.Collection{NonFungibleToken.CollectionPublic, NonFungibleToken.Receiver, Domains.CollectionPublic}>(Domains.CollectionPublicPath, target: Domains.CollectionStoragePath) } else { user.save(<- Domains.createEmptyCollection(), to: Domains.CollectionStoragePath) user.link<&Domains.Collection{NonFungibleToken.CollectionPublic, NonFungibleToken.Receiver, Domains.CollectionPublic}>(Domains.CollectionPublicPath, target: Domains.CollectionStoragePath) } } self.receiver = userAcc.getCapability<&{NonFungibleToken.Receiver}>(Domains.CollectionPublicPath) self.client = flowns.borrow<&{Flowns.AdminPrivate}>(from: Flowns.FlownsAdminStoragePath) ?? panic("Could not borrow admin client") } execute { self.client.mintDomain(domainId: 2, name: name, duration: 3153600000.00, receiver: self.receiver) // 这里将根域名 id 与租用时限进行硬编码 } }
上面的代码有点复杂,概括地说是定义了一个管理员 mint 域名的脚本,通过三方的多签授权完成执行,我们可以只关注注释的部分:
- 交易参数:只保留了域名名称参数,简化了第三方调用和操作
- 授权参数(prepare): 这里使用到了三个签名的授权,这笔交易需要三份签名才能够执行,我们也会在交易体中看到 authorizers 中是三个地址,同时 payloadSignatures 也需要三个签名的元素
- 用户 —— 交易的发起方 ,也就是申请 Lilico 根域名的用户(proposer)
- Lilico —— 交易的校验和授权方,交易手续费的支付方(payer)
- Flowns —— 铸造权限授权方(Flowns admin)
- 另外在脚本里做了两个判断
- 判断用户的 Flow 余额是否足够初始化 Flowns Collection 资源, 如果不够,则通过 Flowns 的账户进行补贴
- 判断用户是否已经初始化了 Flowns Collection ,如果没有则帮助用户初始化 Collection
- 执行代码块中,将管理权 mintDomain 的权限通过硬编码参数进行了权限隔离,该脚本只允许调用 RootDomainId 为 2 的根域名,也就是说只能通过 Lilico 的根域名发行 Flowns 域名,也就自然而然的隔离了其他根域名的权限。
如何解决用户手续费不足或者没有足够的 Flow 的情况下让其能够拥有初始化的资源
其实在前面脚本的注释处我们不难发现,Cadence 脚本已经针对用户的不同情况进行了处理,帮助用户补足费用和在没有资源的情况下提前初始化。这样就能保证在各种条件下,脚本都能够执行成功。
这就是 Cadence 令人着迷的地方。
如何处理高并发
我们来看交易体
{ script: 'Cadence script ...', // Cadence 交易脚本 refBlock: 'd719c892c80b45de3d19c459dc7eeba8ab809b5f3b007a51c69f697b7eb42361', // 引用的区块 ID arguments: [ { type: 'String', value: 'Test' } ], // Cadence 交易的参数 proposalKey: { address: '0xe242ccfb4b8ea3e2', keyId: 0, sequenceNum: 480 }, // 交易发起人的信息 payer: '0xc6de0d94160377cd', // 交易费用支付人信息 authorizers: [ '0xe242ccfb4b8ea3e2', '0xc6de0d94160377cd', '0xb05b2abb42335e88' ], gasLimit: 1000, payloadSignatures: [ { address: '0xe242ccfb4b8ea3e2', keyIndex: 0, signature: '9aa7ca757c0c995d1cc11b71c6c5d3b83be92708905c8ba4528fadc602438acd027b479e1546f9ed96a3c9f8df424eccd318333c7f55378ce4994b164972c450' },{...},{...} ], envelopeSignatures: [ { "address": "0x01", "keyId": 1, "sig": "0xabc123" } ] }
这里的
proposalKey
我们使用的是用户作为 proposer, 这样在处理多用户同时请求的情况下,并不会因为交易授权者本身的 sequenceNum 因为等待交易区块确认的原因而阻塞或冲突,通过用户作为 proposer 在理论上是可以做到无限的并发,区块的吞吐能力不会受单点阻塞,也就解决了多用户同时请求的问题,Lilico 和 Flowns 只需要专注在验证和授权环节即可。这也是我们构建服务端授权和多签场景中需要注意的点,一旦涉及到高并发的请求,最好的方式是让请求发起人作为 proposer ,就能避免阻塞和一些不必要的交易监听和错误处理。
至此我们看到了 Cadence 脚本的灵活性和权限隔离的实现,在灵活的授权和签名机制下,做到用简单的代码,实现了区块链级别的安全。
具体流程
方便大家理解,可以结合上述内容与下图理解整体的三方参与授权的流程:
注意事项
这个方案并不是最优解,但可以基于此构建更加通用的授权和签名方案。
那么在服务端比较核心的仍然是私钥的管理,由于 Flowns 使用的是第三方的 servless 服务,私钥的配置就需要使用托管服务的环境变量完成,虽然配置了账号登录和 2FA, 这也并非万无一失,建议核心合约的管理员权限定时更换 Key 是比较建议的方式。
因为 Flow 账户模型设计比较抽象,也支持 key weight 的形式实现多签,定期更换 key 是比较理想的高安全性的做法。
虽然 Cadence 的优势在于灵活性,但其也有一定被攻击和滥用的风险,所以涉及到核心的授权操作,必须要进行脚本的验证,最为理想的情况是完全通过链上合约的能力实现授权和权限的分割与隔离管理。
下一篇文章将会详细说明如何通过合约实现一个简单的基于资源的中心化授权分权的智能合约逻辑。
最后
此方案只是为了简化合约和满足合约管理员授予第三方合作伙伴受限的权限而设计,并不适用于所有情况,最理想的应当使用链上的逻辑和管理方式来进行根域名的管理。
由于 Cadence 的特性和优势,我们可以通过不修改现有合约的方式来完成灵活的权限授予,且不影响整个交易安全的情况下,通过公开的接口和权限配置完成了多方签名授权和验证。
这可以适用于未来基于智能合约组合的类似的场景,也可以作为一个链下的过渡方案,帮助一些基础设施完成第三方服务的对接和协作。
当然笔者也会继续在 Cadence 应用层探索,尝试更多的业务类型并总结优化现有的模型,尽可能的用最佳实践解决复杂问题,将 Cadence 作为新一代智能合约语言的特性与优势在应用层发挥到极致。