
1. 项目概述为什么你的个人项目急需TOTP双因素认证最近在整理自己几个线上项目的安全日志时我发现了一个让我后背发凉的趋势针对个人开发者项目的自动化撞库和密码喷洒攻击正变得越来越普遍。你可能觉得自己的小项目“无足轻重”但在攻击者眼中这些项目往往是安全防护最薄弱、最容易得手的“跳板”。仅仅依赖“用户名密码”这套单薄的防线就像用一把挂锁去守护金库大门早已不合时宜。双因素认证2FA正是解决这个问题的关键。它要求用户在输入密码你知道的东西之外再提供一个动态的、一次性的验证码你拥有的东西从而将安全等级提升一个维度。而TOTP基于时间的一次性密码算法协议因其开源、标准化、无需网络连接即可生成验证码的特性成为了个人项目集成2FA的黄金标准。Google Authenticator、Microsoft Authenticator等App都是其热门客户端。这个项目就是要手把手地带你为一个典型的个人Web项目比如用Spring Boot、Flask或Express搭建的博客后台、API服务、管理面板从零开始集成TOTP双因素认证。我们不依赖任何重量级的安全框架而是聚焦于核心原理和最小化实现让你彻底搞懂从密钥生成、二维码绑定到服务端校验的每一个环节。学完它你不仅能为自己项目的大门加上一把可靠的“数字U盾”更能深入理解现代认证安全的核心机制。2. 核心原理拆解TOTP是如何工作的在动手写代码之前我们必须先弄清楚TOTP到底是怎么一回事。很多人只是扫码绑定、输入六位数字但对背后的机制一无所知这会导致在调试和排查问题时无从下手。2.1 从HOTP到TOTP一次一密的演进TOTP并非凭空诞生它是在HOTP基于HMAC的一次性密码算法基础上的一个关键改进。HOTP的核心是“计数器”。服务器和客户端共享一个密钥并且同步维护一个递增的计数器。每次认证时客户端用“密钥当前计数器值”通过HMAC算法计算出一个一次性密码。服务器收到后用自己存储的计数器值进行相同计算并比对。验证成功后双方计数器同时加1。它的缺点是如果客户端生成密码但未使用就会导致服务器和客户端的计数器不同步。TOTP用一个更巧妙的思路解决了同步问题用时间代替计数器。它将时间轴分割成一个个固定的时间片默认30秒。计算密码时不再使用计数器而是使用“从Unix纪元1970年1月1日开始到当前时间经历了多少个时间片”这个数字。只要服务器和客户端的时间大致同步通常允许有1-2个时间片的误差就能保证生成的密码一致。注意这里的时间同步是系统时钟的同步而非网络时间协议NTP的严格同步。允许的“窗口”正是为了应对网络延迟和时钟漂移。2.2 六位数字的诞生分步计算详解当你按下Authenticator App上的刷新按钮看到那串6位数字时背后发生了以下计算计算时间计数器CC floor(当前Unix时间戳 / 时间步长)例如当前时间戳是 16788864002023-03-16 08:00:00 UTC时间步长为30秒则C floor(1678886400 / 30) 55962880。计算HMAC-SHA1值 使用共享密钥Key和时间计数器C作为输入通过HMAC-SHA1算法计算出一个20字节的哈希值。HMAC HMAC-SHA1(Key, C)动态截取Dynamic Truncation 这是最精妙的一步。取上一步HMAC结果的最后一个字节的低4位作为一个偏移量offset。 例如最后一个字节是0x5A则低4位是0xA即十进制10。 然后从HMAC结果数组的第offset位开始连续取4个字节31位组成一个31位的大整数。生成最终密码 将上一步得到的大整数对10^6即100万取模得到一个范围在0-999999之间的数字。 如果不足6位则在前面补0最终形成我们看到的6位数字验证码。这个过程是完全确定性的。只要密钥相同、时间片计数相同任何遵循RFC 6238标准的实现无论是Google Authenticator、Authy还是你的服务器都会算出完全相同的6位数。2.3 密钥的奥秘Base32编码与安全存储共享密钥是TOTP安全的基石。它通常是一个16字节128位或20字节160位的随机字节序列。为了方便在二维码中传输和人工录入如果需要这个二进制密钥会被编码成Base32字符串。Base32编码RFC 4648使用32个字符A-Z和2-7每5位二进制数据编码成一个字符。一个16字节128位的密钥编码后大约是26个字符。你在Authenticator App扫描二维码后App解码得到的就是这个原始密钥并安全地存储在本地。实操心得务必在服务端使用密码学安全的随机数生成器如Java的SecureRandomNode.js的crypto.randomBytes来生成这个密钥。任何可预测的“随机”都是致命的。3. 项目架构与核心组件设计理解了原理我们就可以开始设计自己的TOTP认证模块了。对于一个典型的个人项目我们追求的是轻量、清晰、易于集成。下图展示了一个最小化但功能完整的架构设计用户请求 │ ▼ [Web 应用] ──需要2FA的API请求──▶ [TOTP认证过滤器] │ │ │ ▼ │ [提取用户名、验证码] │ │ │ ▼ ◀─────────[验证失败返回401]────[TOTP验证服务] │ │ │ ▼ ◀─────────[验证成功放行请求]────[校验密钥 时间片] │ ▼ [业务逻辑处理]3.1 核心服务层TOTP引擎这是整个系统的心脏负责所有算法相关的操作。我们需要一个TOTPService类它至少提供三个核心方法generateSecret(): 生成一个新的随机密钥并返回Base32编码的字符串和用于生成二维码的URI。verifyCode(String secretKey, String code): 验证给定的验证码是否与密钥匹配。getQRCodeURI(String username, String secret, String issuer): 生成符合otpauth://协议的URI字符串这是二维码的内容。otpauth://URI的格式如下otpauth://totp/{Issuer}:{Username}?secret{Secret}issuer{Issuer}algorithmSHA1digits6period30其中Issuer发行者和Username用户名会显示在Authenticator App中帮助用户区分不同的账户。3.2 数据存储层密钥仓库密钥必须被安全地存储。对于个人项目我们可以从简单方案开始内存存储仅用于原型用一个ConcurrentHashMap在内存中维护用户名 - 密钥的映射。服务器重启数据即丢失仅用于开发和测试。配置文件/环境变量将密钥以加密形式存储在配置文件或环境变量中。适合用户极少且固定的场景。数据库存储推荐在用户表中新增一个字段如totp_secret来存储加密后的密钥。这是最灵活、可扩展的方案。重要安全警告绝对不要以明文存储密钥即使存储在数据库也应使用加密算法如AES-GCM进行加密加密密钥主密钥则通过环境变量或密钥管理服务注入。3.3 认证拦截层请求过滤器/中间件我们需要一个组件在请求到达业务逻辑之前对需要保护的端点进行拦截和验证。在Spring Boot中这通常是一个OncePerRequestFilter在Express.js中是中间件在Flask中是装饰器或before_request钩子。它的职责很明确检查当前请求的路径是否需要TOTP验证可通过注解、配置列表等方式定义。从请求中提取用户标识通常从已通过密码认证的会话或JWT令牌中获得和TOTP验证码通常来自HTTP Header如X-TOTP-Code。根据用户标识从密钥仓库中取出对应的密钥。调用TOTPService.verifyCode进行验证。验证通过则放行失败则立即中断请求返回401 Unauthorized或403 Forbidden。3.4 用户交互层绑定与验证接口我们需要提供两个关键的HTTP端点绑定接口 (GET /api/2fa/setup)用户登录后首次启用2FA时调用。服务端生成密钥和二维码URI返回给前端。前端将其渲染成二维码图片供用户扫描。验证接口 (POST /api/2fa/verify)用户扫描绑定后输入App中显示的6位码进行首次验证确保绑定成功。此后在需要2FA的登录或操作中客户端调用此接口或在请求头中附带验证码。4. 手把手实现基于Spring Boot的完整集成示例下面我将以一个Spring Boot 3.x项目为例展示从零集成的完整步骤。我们将使用一个流行的Java TOTP库com.warrenstrange:googleauth。4.1 第一步环境准备与依赖引入创建一个新的Spring Boot项目或在现有项目中添加依赖。以Maven为例dependency groupIdcom.warrenstrange/groupId artifactIdgoogleauth/artifactId version1.5.0/version /dependency !-- 用于生成二维码 -- dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.2/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.2/version /dependency4.2 第二步实现TOTP核心服务创建TOTPService.javaimport com.google.zxing.BarcodeFormat; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import com.warrenstrange.googleauth.GoogleAuthenticator; import com.warrenstrange.googleauth.GoogleAuthenticatorKey; import com.warrenstrange.googleauth.GoogleAuthenticatorQRGenerator; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.util.Base64; Service public class TOTPService { private final GoogleAuthenticator gAuth; public TOTPService() { this.gAuth new GoogleAuthenticator(); // 可选自定义配置如时间窗口大小 // GoogleAuthenticatorConfig config new GoogleAuthenticatorConfig.GoogleAuthenticatorConfigBuilder() // .setTimeStepSizeInMillis(TimeUnit.SECONDS.toMillis(30)) // .setWindowSize(3) // 允许前后一个时间窗口的误差 // .build(); // this.gAuth new GoogleAuthenticator(config); } /** * 为新用户生成一个TOTP密钥 * return Base32编码的密钥字符串 */ public String generateNewSecret() { GoogleAuthenticatorKey key gAuth.createCredentials(); return key.getKey(); } /** * 验证TOTP代码 * param secretKey Base32编码的密钥 * param verificationCode 用户输入的6位验证码 * return 验证是否成功 */ public boolean verifyCode(String secretKey, int verificationCode) { // 库方法默认处理时间窗口偏移 return gAuth.authorize(secretKey, verificationCode); } /** * 生成用于二维码的 otpauth:// URI * param username 用户名 * param secret Base32密钥 * param issuer 发行者你的应用名 * return otpauth URI */ public String generateQRCodeURI(String username, String secret, String issuer) { return GoogleAuthenticatorQRGenerator.getOtpAuthURL(issuer, username, new GoogleAuthenticatorKey.Builder(secret).build()); } /** * 根据URI生成二维码图片的Base64字符串便于前端img标签直接显示 * param uri otpauth URI * param width 图片宽 * param height 图片高 * return Base64编码的PNG图片字符串 */ public String generateQRCodeBase64(String uri, int width, int height) throws Exception { QRCodeWriter qrCodeWriter new QRCodeWriter(); BitMatrix bitMatrix qrCodeWriter.encode(uri, BarcodeFormat.QR_CODE, width, height); BufferedImage bufferedImage MatrixToImageWriter.toBufferedImage(bitMatrix); ByteArrayOutputStream os new ByteArrayOutputStream(); ImageIO.write(bufferedImage, PNG, os); return Base64.getEncoder().encodeToString(os.toByteArray()); } }4.3 第三步设计密钥存储与用户关联我们采用数据库存储。假设已有User实体为其增加字段import jakarta.persistence.*; import lombok.Data; Entity Data Table(name users) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private String passwordHash; // 新增2FA相关字段 private String totpSecret; // 加密存储的TOTP密钥 private boolean totpEnabled false; // 是否已启用2FA // ... getters and setters }创建一个服务来管理密钥的存储与验证import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; Service public class UserTOTPService { Autowired private UserRepository userRepository; Autowired private TOTPService totpService; // 从环境变量获取加密主密钥务必妥善保管 private final byte[] encryptionKey Base64.getDecoder().decode(System.getenv(TOTP_ENCRYPTION_KEY)); /** * 为用户开启2FA生成密钥加密存储返回二维码信息 */ Transactional public TwoFASetupResponse enable2FA(Long userId, String issuer) throws Exception { User user userRepository.findById(userId).orElseThrow(); String rawSecret totpService.generateNewSecret(); String encryptedSecret encryptSecret(rawSecret); user.setTotpSecret(encryptedSecret); user.setTotpEnabled(false); // 待验证后才真正启用 userRepository.save(user); String qrCodeURI totpService.generateQRCodeURI(user.getUsername(), rawSecret, issuer); String qrCodeBase64 totpService.generateQRCodeBase64(qrCodeURI, 200, 200); return new TwoFASetupResponse(qrCodeURI, qrCodeBase64, rawSecret); // 注意仅在此处返回一次明文密钥给前端用于显示之后丢弃 } /** * 验证用户首次绑定的TOTP码验证通过后正式启用 */ Transactional public boolean verifyAndEnable2FA(Long userId, int code) { User user userRepository.findById(userId).orElseThrow(); String decryptedSecret decryptSecret(user.getTotpSecret()); boolean isValid totpService.verifyCode(decryptedSecret, code); if (isValid) { user.setTotpEnabled(true); userRepository.save(user); } return isValid; } /** * 验证登录或操作时的TOTP码 */ public boolean verify2FACode(Long userId, int code) { User user userRepository.findById(userId).orElseThrow(); if (!user.isTotpEnabled()) { return true; // 未启用2FA的用户直接通过 } String decryptedSecret decryptSecret(user.getTotpSecret()); return totpService.verifyCode(decryptedSecret, code); } // --- 简单的AES-GCM加密解密示例生产环境请使用更健壮的密钥管理方案--- private String encryptSecret(String plainText) throws Exception { Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); byte[] iv new byte[12]; // GCM推荐12字节IV SecureRandom.getInstanceStrong().nextBytes(iv); GCMParameterSpec parameterSpec new GCMParameterSpec(128, iv); SecretKeySpec keySpec new SecretKeySpec(encryptionKey, AES); cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec); byte[] cipherText cipher.doFinal(plainText.getBytes()); // 将IV和密文一起存储 byte[] combined new byte[iv.length cipherText.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); return Base64.getEncoder().encodeToString(combined); } private String decryptSecret(String encryptedBase64) { try { byte[] combined Base64.getDecoder().decode(encryptedBase64); byte[] iv new byte[12]; byte[] cipherText new byte[combined.length - 12]; System.arraycopy(combined, 0, iv, 0, 12); System.arraycopy(combined, 12, cipherText, 0, cipherText.length); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); GCMParameterSpec parameterSpec new GCMParameterSpec(128, iv); SecretKeySpec keySpec new SecretKeySpec(encryptionKey, AES); cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec); byte[] plainText cipher.doFinal(cipherText); return new String(plainText); } catch (Exception e) { throw new RuntimeException(Failed to decrypt TOTP secret, e); } } }4.4 第四步实现认证过滤器创建一个过滤器保护需要2FA的API端点。这里我们假设用户已通过基础登录认证其身份信息如userId已存储在安全上下文如Spring Security的SecurityContextHolder中。import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import java.io.IOException; Component Order(2) // 在Spring Security过滤器链之后执行 public class TOTPAuthFilter implements Filter { Autowired private UserTOTPService userTOTPService; // 定义需要2FA验证的路径模式 private static final String[] PROTECTED_PATHS {/api/admin/**, /api/settings/**}; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; String requestURI httpRequest.getRequestURI(); // 检查当前路径是否需要2FA if (requiresTOTPVerification(requestURI)) { // 1. 从安全上下文中获取当前认证的用户ID (这里需要你根据实际认证框架调整) Long currentUserId getCurrentUserIdFromSecurityContext(); if (currentUserId null) { httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, Authentication required); return; } // 2. 从请求头中获取TOTP验证码 String totpCodeHeader httpRequest.getHeader(X-TOTP-Code); if (totpCodeHeader null || totpCodeHeader.trim().isEmpty()) { httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, TOTP code is required); return; } try { int code Integer.parseInt(totpCodeHeader); // 3. 验证TOTP码 boolean isValid userTOTPService.verify2FACode(currentUserId, code); if (!isValid) { httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, Invalid TOTP code); return; } } catch (NumberFormatException e) { httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, Invalid TOTP code format); return; } } // 验证通过继续过滤器链 chain.doFilter(request, response); } private boolean requiresTOTPVerification(String uri) { for (String pattern : PROTECTED_PATHS) { if (uri.startsWith(pattern.replace(/**, ))) { // 简单的前缀匹配生产环境建议使用PathMatcher return true; } } return false; } private Long getCurrentUserIdFromSecurityContext() { // 示例假设你使用Spring Security且Principal是UserDetails // Authentication authentication SecurityContextHolder.getContext().getAuthentication(); // if (authentication ! null authentication.isAuthenticated()) { // MyUserDetails userDetails (MyUserDetails) authentication.getPrincipal(); // return userDetails.getUserId(); // } // return null; // 此处为演示返回一个模拟ID return 1L; } }4.5 第五步提供用户绑定的REST API最后创建控制器提供前端所需的绑定和验证接口。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/2fa) public class TwoFAController { Autowired private UserTOTPService userTOTPService; GetMapping(/setup) public ResponseEntity? setup2FA(RequestParam Long userId) { try { // 发行者名称会显示在Authenticator App中 String issuer MyPersonalApp; TwoFASetupResponse response userTOTPService.enable2FA(userId, issuer); return ResponseEntity.ok(response); } catch (Exception e) { return ResponseEntity.internalServerError().body(Failed to setup 2FA: e.getMessage()); } } PostMapping(/verify) public ResponseEntity? verifyAndEnable(RequestBody Verify2FARequest request) { boolean success userTOTPService.verifyAndEnable2FA(request.getUserId(), request.getCode()); if (success) { return ResponseEntity.ok().body(2FA enabled successfully.); } else { return ResponseEntity.badRequest().body(Invalid verification code.); } } } // 简单的请求/响应对象 class TwoFASetupResponse { private String qrCodeUri; private String qrCodeImageBase64; private String secret; // 仅用于首次显示前端应提示用户备份 // constructor, getters, setters... } class Verify2FARequest { private Long userId; private int code; // getters, setters... }5. 前端集成与用户操作流程服务端准备好后前端需要配合完成用户引导。流程如下用户登录并进入安全设置用户在自己的账户安全设置页面点击“启用双因素认证”。调用绑定接口前端调用GET /api/2fa/setup?userIdxxx。后端返回包含二维码图片Base64格式和明文密钥用于备用的响应。展示二维码与备用密钥将qrCodeImageBase64填入img srcdata:image/png;base64,{...}显示二维码。同时将secret以文本形式清晰展示给用户并提示“请将此备用密钥安全保存。如果无法扫描二维码可在App中手动输入此密钥。”用户扫描与输入用户打开Google Authenticator App点击“”号扫描屏幕上的二维码。App会自动添加账户并开始生成动态验证码。首次验证用户将App中显示的6位数字输入到前端的验证框点击“验证并启用”。完成启用前端调用POST /api/2fa/verify提交验证码。验证成功后用户的2FA状态被激活。后续受保护操作当用户访问需要2FA的页面如管理后台时前端需要拦截请求例如使用Axios拦截器弹出输入框要求用户输入当前的TOTP码并将其添加到请求头X-TOTP-Code中然后发送请求。6. 常见问题、排查技巧与进阶优化在实际集成过程中你几乎一定会遇到下面这些问题。这里是我踩过坑后总结的排查清单和优化建议。6.1 验证码总是无效一步步排查这是最常见的问题。请按以下顺序检查问题可能点检查方法解决方案时间不同步对比服务器和手机的系统时间精确到秒。确保服务器使用NTP同步时间。在TOTP服务配置中适当调大windowSize如设置为3允许前后90秒的误差。密钥不一致在服务器端对指定用户调用generateNewSecret生成一个新密钥A。在数据库中找到该用户的加密密钥解密后得到密钥B。比对A和B是否一致。检查密钥的生成、加密、存储、解密流程。确保绑定流程中最终存入数据库的密钥是用户扫描的那个。一个常见错误是绑定接口返回的密钥用于生成二维码但忘记将其加密保存到数据库。编码问题确认密钥的Base32编码和解码使用的是RFC 4648标准且没有混入空格或换行。使用可靠的库如Google Authenticator库自带的方法处理Base32编码。手动处理时务必注意字母“I、L、O”和数字“1、0”的区分。验证码输入错误验证码有效期默认30秒输入时可能已过期。提示用户在验证码刷新到新值后有充足时间输入。前端可以添加倒计时显示。URI格式错误检查生成的otpauth://URI特别是issuer和label参数是否包含非法字符如冒号。对issuer和username进行URL编码。实操心得调试时可以在服务器端临时添加日志打印出用于计算的密钥解密后、当前时间戳、计算出的时间计数器C以及验证时用户输入的代码。与Authenticator App显示的代码进行比对。同时可以使用在线的TOTP调试工具输入密钥和时间生成验证码进行交叉验证。6.2 安全性强化建议强制备份代码在用户启用2FA时生成一组如10个一次性备份代码随机字符串。让用户下载或打印保存。当用户丢失手机时可以使用备份代码登录并重新绑定。防暴力破解对TOTP验证接口实施速率限制。例如同一用户每分钟最多尝试5次失败后锁定该功能几分钟。会话管理2FA验证通过后可以在服务器会话或JWT令牌中设置一个标志如totpVerifiedtrue并设置较短的有效期如15分钟。在此期间内访问受保护接口无需重复验证。密钥轮换允许用户在怀疑密钥泄露时主动重新生成密钥并绑定。旧密钥应立即作废。6.3 生产环境部署注意事项加密密钥管理示例中的TOTP_ENCRYPTION_KEY硬编码在代码中是极不安全的。必须使用环境变量、云平台的密钥管理服务如AWS KMS, GCP Secret Manager或专门的密钥管理工具如HashiCorp Vault来注入。数据库加密除了应用层加密如果数据库支持透明数据加密TDE也应启用提供双层保护。高可用与时钟同步在多服务器部署时确保所有实例的时钟高度同步使用NTP。否则用户可能在一台服务器上验证成功在另一台却失败。审计日志记录所有2FA的启用、禁用、验证成功/失败事件包括时间、IP和用户代理便于安全审计。6.4 兼容性与用户体验支持多种验证器TOTP是开放标准除了Google AuthenticatorMicrosoft Authenticator、Authy、1Password、Bitwarden等都支持。确保你的二维码和手动输入密钥流程兼容所有这些App。提供手动输入选项始终在二维码旁边提供Base32格式的明文密钥供无法扫描二维码的用户手动输入。清晰的引导文案在绑定页面用简单的步骤告诉用户该做什么“1. 打开Authenticator App2. 点击‘’添加账户3. 扫描下方二维码或手动输入密钥4. 输入App中显示的6位数字。”考虑无网络环境TOTP验证本身不依赖网络但你的绑定接口获取二维码需要网络。对于完全离线的应用需要设计离线分发密钥的机制。集成TOTP双因素认证看似是增加了一道步骤实则是为你个人项目的安全基石浇筑了钢筋混凝土。这个过程不仅能显著提升防御能力更能让你深入理解现代认证体系的运作方式。从今天开始别再让密码成为你唯一的防线。花上几个小时按照上面的步骤为你最珍视的那个项目加上这把可靠的“数字锁”。