该文档是OpenZeppelin对Moonsong-Labs/zksync-social-login-circuit代码仓库进行的安全审计报告,主要内容是利用零知识证明验证Google账户所有权的Circom电路,用于智能账户恢复,审计发现了一些完整性和非确定性问题,并提出了改进代码质量的建议。
类型:账户抽象 时间线:从 2025-03-24 到 2025-04-11 语言:Circom 问题总数:9 (9 个已解决) 严重问题:0 (0 个已解决) 高危问题:0 (0 个已解决) 中危问题:2 (2 个已解决) 低危问题:3 (3 个已解决) 注意 & 补充信息:4 (4 个已解决)
OpenZeppelin 审计了 Moonsong-Labs/zksync-social-login-circuit 仓库在 commit 27cda6e 的版本。审计范围包括以下文件:
├── jwt-tx-validation.circom
└── utils
├── array.circom
├── base64url-to-base64.circom
├── bytes-to-field.circom
├── bytes.circom
├── constants.circom
├── fields.circom
├── jwt-data.circom
├── jwt-verify.circom
├── replace-all.circom
├── verify-nonce.circom
└── verify-oidc-digest.circom
账户恢复在 web2 中被广泛使用,并且可以认为对于确保最终用户的无缝体验至关重要。使用零知识证明将此功能引入区块链账户,符合 Matter Labs 创建统一 web3 体验的目标。
经过审计的代码库由 Moonsong Labs 与 Matter Labs 合作开发,包含用于验证与单点登录 (SSO) 提供商关联的账户所有权的 Circom 电路。该代码库最初将提供对 Google 账户的支持。这种账户所有权验证尤其可以用于智能钱包账户的恢复,允许钱包所有者注册一个 Google 账户作为恢复方法,并在之后使用它来恢复对其钱包的访问。这种账户恢复方法已经在 web2 中被广泛使用,例如,可以在私钥丢失的情况下提供安全且用户友好的解决方案。使用零知识证明为了在公共区块链上使用提供了额外的保密性优势,因为 Google 账户和地址之间的链接可以保持私密。
上述账户恢复协议由两个部分组成:
OidcValidator
)负责维护 Google 的 RSA 公钥注册表,管理注册账户和后续恢复账户的逻辑。这种恢复通过调用 Groth16 验证器来完成,该验证器验证算术电路可满足性的零知识论证。.circom
文件代表上述第 2 点中描述的电路,并构成了本次审计的范围。第 1 点中提到的智能合约不在此报告的范围之内,并将作为单独的约定进行审计。
审查的代码库使用了一些标准和协议(SSO、OIDC 和 JWT),下面将简要介绍它们。
从高层次上讲,SSO 是一种架构模式,用户可以通过一次身份验证来访问多个服务。在当前的系统中,这种架构依赖于两个关键的技术标准:OpenID Connect (OIDC),它标准化了协议框架;以及 JSON Web Tokens (JWT),它提供了加密有效载荷结构。
OIDC 通过标准化用户身份信息在系统之间流动的方式来扩展 OAuth 2.0。当用户通过身份提供商进行身份验证时,OIDC 会生成一个 JWT,其中包含关于用户身份的加密签名的声明。这个 JWT 成为可移植的身份验证证明,第三方服务(在本例中为账户恢复协议)可以独立验证。
JWT 是一种紧凑、自包含的Token,用于在各方之间安全地传输声明。它们的结构由三个由点分隔的 base64url 编码部分组成:头部、有效载荷和签名 ( header.payload.signature
)。头部是一个 JSON,包含用于生成签名的算法信息,而有效载荷是一个 JSON,包含“声明”(关于 JWT 的发行者、用户和元数据(如过期时间)的语句)。最后,签名为 JWT 提供身份验证和数据完整性保护。
恢复过程分两个主要步骤进行:
OidcValidator
智能合约将他们的 Google 账户与其智能钱包相关联。为了确保不泄露有关使用了哪个 Google 账户的信息,这是通过提交一个 digest
来完成的。这个 digest
是 iss || aud || sub || salt
的 Poseidon 哈希,其中 iss
、aud
和 sub
分别是 JWT 声明,用于标识发行者 (Google)、受众(JWT 的目标对象)和主题(用户)。salt
是一个由 salt service 生成的秘密字段元素。默认的 salt 服务运行在服务器上,并根据 JWT 和静态密钥伪随机但确定性地生成 salt。用户需要在账户恢复阶段能够重现这个相同的 salt,这就是确定性非常重要的原因。理论上,Google 可以确定发行者、受众和主题:向 Poseidon 哈希添加一个秘密 salt 可以防止 Google 从这个摘要中识别出账户。
恢复证明:在恢复账户时,用户通过辅助账户与 OidcValidator
合约进行交互。为了避免潜在的账户恢复重放问题,并验证 Google 账户和辅助地址之间的链接,用户通过以下方式生成 nonce
:
计算 nonce 内容,content = Keccak256(auxiliaryAddress || autoIncrementalValue || timeLimit)
,其中
auxiliaryAddress
是新的辅助账户的地址,用户正尝试从该账户恢复其旧账户。autoIncrementalValue
是每个账户的 nonce,每次 Verifier 智能合约收到有效的证明时,该 nonce 都会递增。timeLimit
是恢复过程的到期日期。content[0]
和 content[1]
,分别表示 31 字节和 1 字节的字符串。blindingFactor
。这个值是一次性的,用于防止 Google 将用户的智能钱包与其 Google 账户相关联。nonce = Poseidon(content[0], content[1], blindingFactor)
。生成后,这个nonce
作为 JWT 的一个额外的用户控制字段发送,并由身份提供者(Google)签名。然后,OidcValidator
合约调用 zk 验证器,将 Google 的 RSA 公钥、摘要和 nonce 内容作为公共输入提供。
该电路具有多项职责:
iss
、aud
和 sub
字段,以重建摘要并根据作为公共输入给出的 digest
验证它。这验证了钱包所有者确实将签署了 JWT 的 Google 账户链接到了它的钱包。blindingFactor
重新计算 nonce
,并验证它是否等于从 JWT 中提取的 nonce
。这确保了 Google 账户验证了 nonce
,从而验证了辅助地址。在审计期间,做出了以下信任假设:
发现某些输入信号约束不足:
nonceLength
、issLength
、audLength
和 subLength
输入信号的位大小。虽然 LessThan
应用[1] [2]于这些值,但它们可能溢出设置为参数的位数。这可能导致 LessThan
返回错误的值。例如,nonceLength
可以被替换为 p−kp-kp−k,其中 kkk 很小,这可能导致 ExtractNonce
返回的 nonce 具有前导和/或尾随 0。其他长度及其关联字段也是如此。虽然这里没有观察到可以直接利用的安全问题,但这仍然是一种危险且可避免的模式。考虑使用 Num2Bits
对这些长度进行范围检查,以避免潜在问题。nonce
假定是 使用 base64url 编码的。因此,nonce
被解码,将 ASCII 字符 45
("-") 替换为 43
("+"),将 95
("_") 替换为 47
("/"),并将 0
("NULL") 替换为 61
("=")。但是,由于 nonce 是用户控制的输入,因此 nonce 中的某些字符可能已经被 base64 编码。例如,nonce abc-
和 abc+
会导致相同的 base64url 解码 nonce,从而引入不确定性。请注意,没有观察到由此产生的具体安全问题,并且验证 nonce
的严格 base64url 编码可能代价太高。因此,只需考虑记录此行为即可。nonce
是一个 32 字节的值,它从 JWT 有效负载中进行 base64url 解码。这 32 个字节被打包在 packedNonce
信号中,等于 nonce[31] + 2 ** (8) * nonce[30] + ... + 2 ** (8 * (31)) * nonce[0]
。但是,这个值可能超过 p
并溢出,例如,如果 nonce[0] = 255
。虽然没有观察到利用这种不确定性的具体场景,但仍然考虑对 nonce 进行范围检查,以确保它是一个适当的字段元素。或者,可以考虑记录这种潜在的溢出。考虑通过额外的范围检查或文档来处理已识别的实例,以减轻由此产生的问题的风险。虽然没有发现具体的攻击,但应尽可能避免不确定性,否则应记录在案。
更新:已在提交 5d02de9 的 pull request #40 中解决。所有已识别的问题都已解决。添加了一个新的 AssertFitsBinary
函数来对长度进行范围检查并解决第一个问题。现在验证 nonce
不包含 "+" 或 "/" 字符,这些字符不是有效的 base64url 字符,从而解决了第二个问题。添加了新函数来检测计算 nonce 时的溢出,从而解决了第三个问题中的担忧。
RFC-7519 标准化了 JWT 的概念,将其描述为“由句点('.')字符分隔的一系列 URL 安全的部分。每个部分都包含一个 base64url 编码的值”。但是,电路解码有效负载,就好像它是 base64 编码的,而不是 base64url 编码的。如果有效负载包含 "-" 或 "_",电路会 拒绝这些输入,因为它们不对应于有效的 base64 字符。但是,这些是 base64url 编码中的有效字符。这使得从理论上讲,可能存在有效的 Google OIDC JWT,但电路无法验证,从而破坏了完备性。
注意:在测试期间,审计团队无法在正常约束下生成此类 JWT(例如,电子邮件地址中只有 ASCII 字符,nonce
正确设置为 32 字节哈希的 base64url 编码和其它与 Google 兼容的字段)。但是,测试并非详尽无遗,并且应 base64url 解码有效负载,以降低出现问题的风险。
考虑在解码有效负载之前调用 Base64UrlToBase64
,类似于 nonce 的处理方式,以避免潜在的完备性问题。
更新:已在提交 0548942 的 pull request #40 中解决。现在有效负载已正确进行 base64url 解码。
在 verify-oidc-digest.circom
中,中间信号数组 packedIss
的长度被给出为 computeIntChunkLength(issFieldLength)
。但是,issFieldLength
是 iss
的字段元素长度,而不是字节长度,并且 先前计算为 var issFieldLength = computeIntChunkLength(maxIssLength)
。最终的结果是数组的长度被计算为 computeIntChunkLength(computeIntChunkLength(maxIssLength))
而不是 computeIntChunkLength(maxIssLength)
。在这种情况下,碰巧它们是相等的,因为 computeIntChunkLength(computeIntChunkLength(maxIssLength)) = computeIntChunkLength(1) = 1
。
实际上,在第 33 行 中有一个 assert
语句约束 issFieldLength == 1
。因此,此错误不能代表当前形式的电路的漏洞。但是,此计算未按预期完成,因此仍值得更正。
考虑使用预先计算的 issFieldLength
替换数组长度的内联计算(即,signal packedIss[issFieldLength] <== PackBytes(maxIssLength)(iss)
)。
更新:已在提交 0548942 的 pull request #40 中解决。
zksync-social-login-circuits 存储库中的许多电路都在表示为原生字段元素的不同数据类型之间进行转换。因此,电路内部的值可以表示
由于最终电路检查的是预期的哈希,因此省略了每种数据类型的输入验证,形式为电路内的范围检查。对于 circom 中的子电路来说,以这种方式编写(即,作为承诺算法)以减少与更大电路中的其他电路组合时的约束是很常见的。
更大的电路确保了安全性,因为如果子电路的任何输入不是承诺 forms(例如,如果预期为字节的字段元素给出的值超出 0-255 的范围),它将无法生成正确的证明。但是,缺少显式的范围检查使得建立命名约定来指定给定原生字段元素中保存的预期“类型”的数据变得更加重要,以避免混淆并使代码自我记录。
为了解决这个问题,请考虑实施以下建议。
例如,
sha[256]
可以是 shaBits[256]
FindRealMessageLength
可以是 FindRealMessageLengthAscii
或类似的东西。blindingFactor
可以是 blindingFactorField
。pubkey[k]
可以是 pubkeyChunks[k]
。例如,在 jwt-verify.circom
中,
messageLength
可以是 messageLengthAscii
。rsaMessageSize
可以是 rsaMessageSizeChunks
。由于 ASCII 也是一种编码类型,因此采用这种措辞可以使输出更加明确。
在 BytesToField
中,使用了大端解释,但未明确说明。可以将名称更改为 BytesToFieldBigEndian
或类似名称,并且文档可以包括使用的解释。
代码库中的大多数名称都具有描述性,但是在某些情况下,使用更具描述性的名称会更有益。例如,
n
可以是 numBitsPerChunk
或类似名称。k
可以是 numChunks
。gts
可以是 comparisonResults
、greaterThans
或类似名称。MAX_NONCE_BASE64_LENGTH
遵循模式 [BOUND]_[OBJECT]_[UNIT]_LENGTH
,而 MAX_LENGTH_DECODED_NONCE
遵循模式 [BOUND]_LENGTH_[UNIT]_[OBJECT]
。考虑建立一个命名约定,该约定具有一致的顺序,以指定整个代码中不同数组长度的测量单位。在此示例中,可以将两个长度分别重命名为 MAX_LENGTH_NONCE_BASE64
和 MAX_LENGTH_NONCE_ASCII
。
更新:已在提交 0548942 的 pull request #40 中解决。Matter Labs 团队根据我们的建议重命名了大多数常量、变量名和信号。如果存在例外情况,则目的是为了与 zkemail 依赖项的表示法保持一致,和/或该名称的含义在文档中。
电路约束字符 46
(ASCII 中的“.”)在 message
中 是唯一的。此外,message
是根据 SHA-256 规范 进行填充的,这意味着它用单个位 1
进行填充,后跟足够的 0,使其与 448 模 512 同余,然后是其长度作为 64 位数字。
但是,从理论上讲,此长度可能包含“46”字符,这会破坏完备性,因为“46”字符将不再是唯一的。审计团队进行的测试表明,鉴于输入长度的 约束,这目前是不可能的。但是,如果将来 maxMessageLength
参数增加,则需要重新调查此问题,以避免潜在的完备性问题。
考虑记录此约束。或者,为了更具前瞻性,请考虑确保长度中出现的 46
字节不会破坏完备性。
更新:已在提交 0548942 的 pull request #40 中解决。现在,使用新的 CountCharOccurrencesUpTo
函数验证句点字符的唯一性,该字符忽略 SHA-256 填充。
在 verify-oidc-digest.circom
中,使用 @zkemail/circuits/utils/bytes.circom
的 computeIntChunkLength
函数计算了三个参数:
var issFieldLength = computeIntChunkLength(maxIssLength);
var audFieldLength = computeIntChunkLength(maxAudLength);
var subFieldLength = computeIntChunkLength(maxSubLength);
但是,在第 37-39 行 中,它们再次内联计算,以给出三个相应数组的长度:
signal packedIss[computeIntChunkLength(issFieldLength)] <== PackBytes(maxIssLength)(iss);
signal packedAud[computeIntChunkLength(maxAudLength)] <== PackBytes(maxAudLength)(aud);
signal packedSub[computeIntChunkLength(maxSubLength)] <== PackBytes(maxSubLength)(sub);
考虑通过直接在第 37-39 行的方括号内使用预先计算的值来简化代码。
更新:已在提交 0548942 的 pull request #40 中解决。
在整个代码库中,发现了多个缺失或有误导性的文档实例:
jwt-tx-validation.circom
的 第22-23行 中,maxMessageLength
和 maxB64PayloadLength
可以被记录为以 base64url 和 ASCII 字符表示的长度。bytes-to-field.circom
的 第4行 中,考虑记录 BytesToField
模板。这个 NatSpec 还应该记录 inputs
数组包含有效字节(即,[0, 255]中的字段元素)的假设,以及输出可能溢出的情况。constants.circom
的 第13行 中,KID 被记录为 "20 bytes long",而 JWT_KID_LENGTH
函数返回 40。考虑澄清注释以说明这种行为的原因。jwt-verify.circom
的 第49行 中,考虑记录以下事实:虽然 AssertZeroPadding
模板假设 startIndex - 1
适合 ceil(log2(maxArrayLen))
位,但如果 messageLength
为 0 则不满足此假设,LessThan
仍然会按预期工作。fields.circom
文件中,ExtractNonce
、ExtractIssuer
、ExtractAud
和 ExtractSub
模板使用 RevealSubstring
作为子模板,maxSubstringLength
和 substringLength
的参数相同(例如,对于 nonce:nonceKeyLength
)。这与 SelectSubArray
中的假设相矛盾,即 length
假设适合 ceil(log2(maxArrayLen))
位,因为例如,nonce 在技术上 substringLength = 8
不适合 ceil(log2(substringLength)) = 3
位。考虑记录这是一个已知的行为,并且 GreaterThan
仍然按预期工作。fields.circom
文件中,考虑在 ExtractNonce
、ExtractIssuer
、ExtractAud
和 ExtractSub
模板中记录 nonceKeyStartIndex
被 RevealSubstring
和 VarShiftLeft
模板隐式验证在正确的范围内(即,[0, 1023])。verify-oidc-digest.circom
的 第18行 中,文档说明 expectedDigest
输入由用户提供。但是,它是由 OidcValidator
智能合约提供的。verify-nonce.circom
的 第32行 中,有一个输入数组被记录为 txHash[2]
,但在代码中显示为 content[2]
。verify-nonce.circom
的 第32行 中,声明 txHash
作为长度为 2 的数组提供,因为它表示 Keccak-256 哈希,并且字段模数为 254 位长。但是,它没有指出这些位如何在两个值之间分割。例如,两种合理的分割方式是 31 字节和 1 字节,或者 16 字节和 16 字节。这改变了 nonce 内容的语义含义,因为 nonce 的计算方式为 Poseidon(3)(content[0], content[1], blindingFactor)
。由于 Poseidon 是一种字段哈希算法,并且不从连接值开始,因此这引入了一些关于读者应该如何预期 nonce 被计算的歧义。考虑添加缺失的注释并修改上述实例,以提高一致性并更准确地反映已实现的逻辑,从而使审计员和其他检查代码的各方更容易理解代码的每个部分旨在做什么。
更新: 已在 pull request #40 的提交 0548942 中解决。Matter Labs 团队采纳了我们的建议,但在他们认为记录如何处理特定边缘情况可能会造成混淆的少数情况下除外。在与他们讨论后,我们同意他们的评估。
在整个代码库中,发现了多个代码改进的机会:
n
和 k
参数,以便 n*k > 2048 对于 RSA-2048 并且 n < 127。此外,maxMessageLength
和 maxB64PayloadLength
必须分别是 64 和 4 的倍数。考虑添加断言以在代码生成期间检查这些假设,以避免错误配置。JWT_TYP_LENGTH
函数返回 11,但应返回 12 以与其文档保持一致(或者其文档不正确)。此外,constants.circom
中的许多函数未使用,可以删除。jwt-data.circom
使用两个空格,而 fields.circom
使用四个空格。verify-nonce.circom
的开头,定义了 MAX_LENGTH_DECODED_NONCE()
和 MAX_BYTES_FIELD()
常量函数,分别返回 33 和 32。由于它们的长度相差 1,因此在 第48行 中添加了一个约束,确保 nonce[32] === 0
。但是,索引 32 表示从索引 MAX_BYTES_FIELD()
开始到索引 MAX_LENGTH_DECODED_NONCE() - 1
结束(包括端点)的单元素子数组。考虑将其更改为 for
循环,约束此子数组的每个条目都为 0,并将 硬编码的 32
更改为 MAX_BYTES_FIELD()
。这将使代码与文件中其余的代码保持一致,并允许协议在将来易于扩展。expectedIss
和 expectedAud
作为私有输入。但是,这些仅用于验证,因为私有输入不会影响系统的安全性。考虑删除它们以简化代码。或者,如果需要此类验证,请考虑添加 expectedSub
以也验证 JWT 的 sub
字段。考虑解决已识别的实例以提高代码质量。
更新: 已在 pull request #40 的提交 0548942 中解决。已解决所有已识别的点。
在整个代码库中,发现了多个排版错误的实例:
base64url-to-base64.circom
的 第27行 中,“from a base64”应为“from a base64url”。此外,JWT 是原始的 base64URL 编码的字符串。考虑重新措辞以使其更清晰。fields.circom
的 第13行 中,NatSpec 中的“maxNonceLength”参数应为“maxPayloadLength”。jwt-data.circom
的 第16行 中,“index o '"nonce":'”应为“index of '"nonce":'”。jwt-data.circom
的 第18-22行 中,行尾的“..”应为“.”。verify-nonce.circom
的 第24行 中,“Our nonce are”应为“Our nonces are”或“Our nonce is”。verify-nonce.circom
的 第27行 中,“Decide”应为“Decode”。verify-nonce.circom
的 第31行 中,“to prevent google to identify”应为“to prevent Google from identifying”。verify-oidc-digest.circom
的 第7行 中,“Recalculate oidc_digest and checks matches with provided one.”应为“Recalculates oidc_digest and checks that it matches the one provided.”verify-oidc-digest.circom
的 第11-13行 中,“Max length if characters”应为“Max length in characters”。verify-oidc-digest.circom
的 第14-17行 中,每个输入名称后的第一个字母应大写。bytes.circom
的 第17行 中,maxLength
参数在 NatSpec 中定义为“@input”,而它应该是“@param”。jwt-tx-validation.circom
的 第33行 中,“Index for "nonce":" substring”应为“Index for '"nonce":' substring”。jwt-verify.circom
的 第45行 中,应在句子末尾添加单词“bits”。为了提高代码库的可读性,请考虑解决上述实例。
更新: 已在 pull request #40 的提交 1e0e35d 中解决。
提出以下建议是为了在恢复协议发生更改时提供帮助:
maxIssLength
太短)而可能出现的完整性问题,但没有发现任何此类问题。但是,如信任假设部分所述,假设唯一支持的提供商是 Google。值得注意的是,sub
参数在理论上可以具有最多 255 个 ASCII 字符的长度,远远高于当前电路的限制 31。如果计划将来支持更多提供商,请考虑验证这些长度参数以确保保持完整性。正在审计的代码库引入了电路,用于通过零知识证明来证明 Google 帐户的所有权,以用于智能帐户恢复。这些电路验证与 JWT 关联的签名,并解析其 payload 以提取验证摘要和 nonce 所需的信息。这种设计显著提供了机密性,隐藏了智能帐户与其关联的 Google 帐户之间的链接。
审计未发现任何重大问题。发现了一些完整性和不确定性问题,并提供了建议以进一步提高代码质量。总体而言,该实现被认为是健全且编写良好的。感谢 Matter Labs 和 Moonsong Labs 团队的积极响应,并向审计团队提供了广泛的项目文档。
- 原文链接: blog.openzeppelin.com/ss...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!