fix(mnemonic): fix recovery-by-mnemonic using hash verification instead of address matching

## Problem
MPC wallet addresses have no cryptographic relationship with recovery mnemonics,
so address-based verification always failed for account recovery.

## Solution
Changed mnemonic verification from address matching to hash-based verification:
- Mnemonic acts as identity credential, verified by hash stored in blockchain-service
- Uses accountSequence to lookup stored mnemonic hash for verification

## Changes

### blockchain-service
- recovery-mnemonic.adapter.ts:
  - generateMnemonic() now async, uses bcrypt (rounds=12) for secure hashing
  - verifyMnemonic() now async, supports both bcrypt and legacy SHA256 hashes
  - Added backward compatibility for existing SHA256 hashed mnemonics
- mnemonic-verification.service.ts:
  - await verifyMnemonic() for async bcrypt comparison
- address-derivation.service.ts:
  - await generateMnemonic() for async bcrypt hashing
- package.json: added bcrypt dependency

### identity-service
- user-application.service.ts:
  - Changed recoverByMnemonic() to use verifyMnemonicByAccount (hash verification)
  - Added rate limiting: 5 failed attempts per hour per accountSequence
  - Uses Redis to track failed verification attempts
- redis.service.ts:
  - Added incr() and expire() methods for rate limiting
  - Added updateKeygenStatusAtomic() with Lua script for atomic state transitions
- mpc-keygen-completed.handler.ts:
  - Uses atomic Redis update to prevent race conditions
- blockchain-wallet.handler.ts:
  - Uses atomic Redis update for completed status

## Security Improvements
- bcrypt with 12 rounds for mnemonic hashing (anti-brute-force)
- Rate limiting prevents brute force attacks on mnemonic recovery
- Atomic Redis operations prevent race conditions in wallet creation flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-08 09:02:24 -08:00
parent d983525aa5
commit 6b85401d5c
9 changed files with 228 additions and 47 deletions

View File

@ -20,6 +20,7 @@
"@prisma/client": "^5.7.0",
"@scure/bip32": "^1.3.2",
"@scure/bip39": "^1.6.0",
"bcrypt": "^6.0.0",
"bech32": "^2.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@ -34,6 +35,7 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
@ -253,6 +255,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1756,6 +1759,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz",
"integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "20.4.1",
"iterare": "1.2.1",
@ -1802,6 +1806,7 @@
"integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@nuxtjs/opencollective": "0.3.2",
"fast-safe-stringify": "2.1.1",
@ -1859,6 +1864,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-10.4.20.tgz",
"integrity": "sha512-zu/o84Z0uTUClNnGIGfIjcrO3z6T60h/pZPSJK50o4mehbEvJ76fijj6R/WTW0VP+1N16qOv/NsiYLKJA5Cc3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"iterare": "1.2.1",
"tslib": "2.8.1"
@ -1917,6 +1923,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz",
"integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==",
"license": "MIT",
"peer": true,
"dependencies": {
"body-parser": "1.20.3",
"cors": "2.8.5",
@ -2390,6 +2397,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -2424,6 +2441,7 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -2561,6 +2579,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -2722,6 +2741,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3079,6 +3099,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3092,7 +3113,6 @@
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
},
@ -3135,6 +3155,7 @@
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@ -3499,6 +3520,20 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bech32": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
@ -3612,6 +3647,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3884,13 +3920,15 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/class-validator": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@ -4645,6 +4683,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -4701,6 +4740,7 @@
"integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -5369,7 +5409,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4.0"
},
@ -6027,6 +6066,7 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
@ -6309,6 +6349,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -7110,6 +7151,7 @@
"resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
"integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
@ -7559,6 +7601,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-emoji": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@ -7589,6 +7640,17 @@
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -8029,6 +8091,7 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -8087,6 +8150,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@ -8131,8 +8195,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
@ -8303,7 +8366,8 @@
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz",
"integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/repeat-string": {
"version": "1.6.1",
@ -8533,6 +8597,7 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@ -8588,6 +8653,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -9556,6 +9622,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -9702,6 +9769,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -9999,7 +10067,6 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@ -10014,7 +10081,6 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@ -10025,7 +10091,6 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",

View File

@ -39,6 +39,7 @@
"@prisma/client": "^5.7.0",
"@scure/bip32": "^1.3.2",
"@scure/bip39": "^1.6.0",
"bcrypt": "^6.0.0",
"bech32": "^2.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@ -53,6 +54,7 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",

View File

@ -82,24 +82,36 @@ export class AddressDerivationService {
// 3. 生成恢复助记词 (与账户序列号关联)
this.logger.log(`[MNEMONIC] Generating recovery mnemonic for account ${accountSequence}`);
const mnemonicResult = this.recoveryMnemonic.generateMnemonic({
const mnemonicResult = await this.recoveryMnemonic.generateMnemonic({
userId: userId.toString(),
publicKey,
});
this.logger.log(`[MNEMONIC] Recovery mnemonic generated, hash: ${mnemonicResult.mnemonicHash.slice(0, 16)}...`);
// 4. 存储恢复助记词到 blockchain-service 数据库 (使用 accountSequence 关联)
await this.prisma.recoveryMnemonic.create({
data: {
// 检查是否已存在,避免重复创建
const existingMnemonic = await this.prisma.recoveryMnemonic.findFirst({
where: {
accountSequence,
publicKey,
encryptedMnemonic: mnemonicResult.encryptedMnemonic,
mnemonicHash: mnemonicResult.mnemonicHash,
status: 'ACTIVE',
isBackedUp: false,
},
});
this.logger.log(`[MNEMONIC] Recovery mnemonic saved for account ${accountSequence}`);
if (existingMnemonic) {
this.logger.warn(`[MNEMONIC] Recovery mnemonic already exists for account ${accountSequence}, skipping creation`);
} else {
await this.prisma.recoveryMnemonic.create({
data: {
accountSequence,
publicKey,
encryptedMnemonic: mnemonicResult.encryptedMnemonic,
mnemonicHash: mnemonicResult.mnemonicHash,
status: 'ACTIVE',
isBackedUp: false,
},
});
this.logger.log(`[MNEMONIC] Recovery mnemonic saved for account ${accountSequence}`);
}
// 5. 发布钱包地址创建事件 (包含所有链的地址和助记词)
const event = new WalletAddressCreatedEvent({

View File

@ -48,8 +48,8 @@ export class MnemonicVerificationService {
};
}
// 2. 使用 RecoveryMnemonicAdapter 验证哈希
const result = this.recoveryMnemonic.verifyMnemonic(mnemonic, recoveryRecord.mnemonicHash);
// 2. 使用 RecoveryMnemonicAdapter 验证哈希 (bcrypt 是异步的)
const result = await this.recoveryMnemonic.verifyMnemonic(mnemonic, recoveryRecord.mnemonicHash);
if (result.valid) {
this.logger.log(`Mnemonic verified successfully for account ${accountSequence}`);

View File

@ -10,6 +10,7 @@ import { validateMnemonic, entropyToMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
export interface GenerateMnemonicParams {
userId: string;
@ -52,7 +53,7 @@ export class RecoveryMnemonicAdapter {
* 2. 12 BIP39
* 3.
*/
generateMnemonic(params: GenerateMnemonicParams): GenerateMnemonicResult {
async generateMnemonic(params: GenerateMnemonicParams): Promise<GenerateMnemonicResult> {
const { userId, publicKey } = params;
this.logger.log(`Generating recovery mnemonic for user=${userId}, publicKey=${publicKey.slice(0, 16)}...`);
@ -83,8 +84,8 @@ export class RecoveryMnemonicAdapter {
// 加密助记词
const encryptedMnemonic = this.encryptMnemonic(mnemonic);
// 计算助记词哈希 (用于验证,不可逆)
const mnemonicHash = this.hashMnemonic(mnemonic);
// 计算助记词哈希 (用于验证,不可逆,使用 bcrypt)
const mnemonicHash = await this.hashMnemonic(mnemonic);
this.logger.log(`Recovery mnemonic generated: hash=${mnemonicHash.slice(0, 16)}...`);
@ -97,16 +98,26 @@ export class RecoveryMnemonicAdapter {
}
/**
*
* (使 bcrypt)
*/
verifyMnemonic(mnemonic: string, expectedHash: string): VerifyMnemonicResult {
async verifyMnemonic(mnemonic: string, expectedHash: string): Promise<VerifyMnemonicResult> {
if (!validateMnemonic(mnemonic, wordlist)) {
return { valid: false, message: 'Invalid mnemonic format' };
}
const hash = this.hashMnemonic(mnemonic);
if (hash !== expectedHash) {
return { valid: false, message: 'Mnemonic does not match' };
// 兼容旧的 SHA256 hash 和新的 bcrypt hash
if (expectedHash.startsWith('$2')) {
// bcrypt hash
const isValid = await bcrypt.compare(mnemonic, expectedHash);
if (!isValid) {
return { valid: false, message: 'Mnemonic does not match' };
}
} else {
// 旧的 SHA256 hash (兼容性)
const hash = this.hashMnemonicSha256(mnemonic);
if (hash !== expectedHash) {
return { valid: false, message: 'Mnemonic does not match' };
}
}
return { valid: true };
@ -146,9 +157,18 @@ export class RecoveryMnemonicAdapter {
}
/**
* ()
* (使 bcrypt)
*/
private hashMnemonic(mnemonic: string): string {
private async hashMnemonic(mnemonic: string): Promise<string> {
// bcrypt rounds = 12, 足够安全且不会太慢
const hash = await bcrypt.hash(mnemonic, 12);
return hash;
}
/**
* SHA256 ()
*/
private hashMnemonicSha256(mnemonic: string): string {
const hash1 = createHash('sha256').update(mnemonic).digest();
const hash2 = createHash('sha256').update(hash1).digest();
return hash2.toString('hex');

View File

@ -110,6 +110,7 @@ export class BlockchainWalletHandler implements OnModuleInit {
}
// 5. Update Redis status to completed (include mnemonic for first-time retrieval)
// Uses atomic operation to ensure proper state transition
const statusData: WalletCompletedStatusData = {
status: 'completed',
userId,
@ -119,13 +120,18 @@ export class BlockchainWalletHandler implements OnModuleInit {
updatedAt: new Date().toISOString(),
};
await this.redisService.set(
const updated = await this.redisService.updateKeygenStatusAtomic(
`${KEYGEN_STATUS_PREFIX}${userId}`,
JSON.stringify(statusData),
'completed',
KEYGEN_STATUS_TTL,
);
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`);
if (updated) {
this.logger.log(`[STATUS] Keygen status updated to 'completed' for user: ${userId}`);
} else {
this.logger.log(`[STATUS] Status not updated for user: ${userId} (unexpected - completed should always succeed)`);
}
// Log all addresses
addresses.forEach((addr) => {

View File

@ -88,6 +88,9 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
* From mpc-service, keygen is complete with public key.
* Update status to "deriving" - blockchain-service will now derive addresses
* and send WalletAddressCreated event which BlockchainWalletHandler will process.
*
* Uses atomic Redis update to ensure status only advances forward:
* pending -> generating -> deriving -> completed
*/
private async handleKeygenCompleted(payload: KeygenCompletedPayload): Promise<void> {
const { publicKey, extraPayload } = payload;
@ -100,10 +103,12 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
const { userId, username } = extraPayload;
this.logger.log(`[STATUS] Keygen completed: userId=${userId}, username=${username}`);
this.logger.log(`[STATUS] Public key: ${publicKey?.substring(0, 30)}...`);
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`);
try {
this.logger.log(`[STATUS] Waiting for blockchain-service to derive addresses...`);
// Update status to "deriving" - waiting for blockchain-service
// Uses atomic operation to ensure we don't overwrite higher-priority status
const statusData: KeygenStatusData = {
status: 'deriving',
userId,
@ -111,14 +116,19 @@ export class MpcKeygenCompletedHandler implements OnModuleInit {
updatedAt: new Date().toISOString(),
};
await this.redisService.set(
const updated = await this.redisService.updateKeygenStatusAtomic(
`${KEYGEN_STATUS_PREFIX}${userId}`,
JSON.stringify(statusData),
'deriving',
KEYGEN_STATUS_TTL,
);
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
if (updated) {
this.logger.log(`[STATUS] Keygen status updated to 'deriving' for user: ${userId}`);
this.logger.log(`[STATUS] blockchain-service will derive addresses and send WalletAddressCreated event`);
} else {
this.logger.log(`[STATUS] Status not updated for user: ${userId} (current status has higher priority)`);
}
} catch (error) {
this.logger.error(`[ERROR] Failed to update keygen status: ${error}`, error);
}

View File

@ -156,27 +156,31 @@ export class UserApplicationService {
if (!account) throw new ApplicationError('账户序列号不存在');
if (!account.isActive) throw new ApplicationError('账户已冻结或注销');
// 获取账户的钱包地址用于验证
const expectedAddresses: Array<{ chainType: string; address: string }> = [];
const kavaWallet = account.getWalletAddress(ChainType.KAVA);
if (kavaWallet) {
expectedAddresses.push({ chainType: 'KAVA', address: kavaWallet.address });
// 检查验证失败次数限制 (防止暴力破解)
const failKey = `mnemonic:fail:${command.accountSequence}`;
const failCountStr = await this.redisService.get(failKey);
const failCount = failCountStr ? parseInt(failCountStr, 10) : 0;
if (failCount >= 5) {
throw new ApplicationError('验证次数过多请1小时后重试');
}
if (expectedAddresses.length === 0) {
throw new ApplicationError('账户没有关联的钱包地址');
}
// 调用 blockchain-service 验证助记词
const verifyResult = await this.blockchainClient.verifyMnemonic({
// 调用 blockchain-service 验证助记词 hash
// 助记词作为身份凭证,通过 hash 匹配验证,不需要与钱包地址关联
const verifyResult = await this.blockchainClient.verifyMnemonicByAccount({
accountSequence: command.accountSequence,
mnemonic: command.mnemonic,
expectedAddresses,
});
if (!verifyResult.valid) {
// 记录失败次数
await this.redisService.incr(failKey);
await this.redisService.expire(failKey, 3600); // 1小时过期
throw new ApplicationError('助记词错误');
}
// 验证成功,清除失败计数
await this.redisService.delete(failKey);
account.addDevice(command.newDeviceId, command.deviceName);
account.recordLogin();
await this.userRepository.save(account);

View File

@ -44,6 +44,68 @@ export class RedisService implements OnModuleDestroy {
await this.client.expire(key, seconds);
}
/**
* keygen
* 使 Lua 脚本确保状态只能向前推进: pending < generating < deriving < completed
* failed completed
*
* @returns true , false
*/
async updateKeygenStatusAtomic(
key: string,
newStatusData: string,
newStatus: string,
ttlSeconds: number,
): Promise<boolean> {
// 状态优先级completed > failed > deriving > generating > pending
// 数字越大优先级越高
const luaScript = `
local key = KEYS[1]
local newData = ARGV[1]
local newStatus = ARGV[2]
local ttl = tonumber(ARGV[3])
--
local priority = {
pending = 1,
generating = 2,
deriving = 3,
failed = 4,
completed = 5
}
local newPriority = priority[newStatus] or 0
--
local currentData = redis.call('GET', key)
if currentData then
local current = cjson.decode(currentData)
local currentStatus = current.status or 'pending'
local currentPriority = priority[currentStatus] or 0
--
if newPriority <= currentPriority then
return 0 --
end
end
--
redis.call('SET', key, newData, 'EX', ttl)
return 1 --
`;
const result = await this.client.eval(
luaScript,
1,
key,
newStatusData,
newStatus,
ttlSeconds.toString(),
);
return result === 1;
}
onModuleDestroy() {
this.client.disconnect();
}