25 KiB
25 KiB
Backup Service 实施指导
1. 概述
1.1 服务定位
backup-service 是 RWA Durian 平台的 MPC 备份分片存储服务,负责安全存储用户的 Backup Share (Party 2)。
核心职责:
- 安全存储 MPC 2-of-3 的第三个分片 (Backup Share)
- 提供分片的存取 API
- 支持用户账户恢复时的分片验证和获取
- 审计日志记录
1.2 安全要求
关键安全原则:必须部署在与 identity-service 不同的物理服务器上!
┌──────────────────────────────────────────────────────────────────┐
│ MPC 分片物理隔离架构 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 物理服务器 A 物理服务器 B 用户设备 │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │
│ │identity-service │ │ backup-service │ │Flutter App │ │
│ │Server Share (0) │ │Backup Share (2) │ │Client (1) │ │
│ └─────────────────┘ └─────────────────┘ └────────────┘ │
│ │
│ 任意单点被攻破,攻击者只能获得 1 个分片,无法重建私钥 │
└──────────────────────────────────────────────────────────────────┘
如果 Server Share 和 Backup Share 在同一台物理服务器,攻击者攻破该服务器后可获得 2 个分片,直接重建私钥,MPC 安全性完全失效!
1.3 技术栈
与 identity-service 保持一致:
- 框架: NestJS 10.x
- 语言: TypeScript 5.x
- 数据库: PostgreSQL 15+ (独立数据库实例)
- ORM: Prisma 5.x
- 缓存: Redis (可选)
- 消息队列: Kafka (可选,用于接收 identity-service 的事件)
2. 目录结构
参考 identity-service 的 DDD + Hexagonal 架构:
backup-service/
├── prisma/
│ ├── schema.prisma # 数据库模型
│ └── migrations/ # 数据库迁移
├── src/
│ ├── api/ # 接口层 (Adapters - Driving)
│ │ ├── controllers/
│ │ │ └── backup-share.controller.ts
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ ├── store-share.dto.ts
│ │ │ │ └── retrieve-share.dto.ts
│ │ │ └── response/
│ │ │ └── share-info.dto.ts
│ │ └── api.module.ts
│ │
│ ├── application/ # 应用层 (Use Cases)
│ │ ├── commands/
│ │ │ ├── store-backup-share/
│ │ │ │ ├── store-backup-share.command.ts
│ │ │ │ └── store-backup-share.handler.ts
│ │ │ └── revoke-share/
│ │ │ ├── revoke-share.command.ts
│ │ │ └── revoke-share.handler.ts
│ │ ├── queries/
│ │ │ └── get-backup-share/
│ │ │ ├── get-backup-share.query.ts
│ │ │ └── get-backup-share.handler.ts
│ │ ├── services/
│ │ │ └── backup-share-application.service.ts
│ │ └── application.module.ts
│ │
│ ├── domain/ # 领域层 (Core Business Logic)
│ │ ├── entities/
│ │ │ └── backup-share.entity.ts
│ │ ├── repositories/
│ │ │ └── backup-share.repository.interface.ts
│ │ ├── services/
│ │ │ └── share-encryption.domain-service.ts
│ │ ├── value-objects/
│ │ │ ├── share-id.vo.ts
│ │ │ └── encrypted-data.vo.ts
│ │ └── domain.module.ts
│ │
│ ├── infrastructure/ # 基础设施层 (Adapters - Driven)
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ └── prisma.service.ts
│ │ │ └── repositories/
│ │ │ └── backup-share.repository.impl.ts
│ │ ├── crypto/
│ │ │ └── aes-encryption.service.ts
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/ # 共享模块
│ │ ├── guards/
│ │ │ └── service-auth.guard.ts # 服务间认证
│ │ ├── filters/
│ │ │ └── global-exception.filter.ts
│ │ └── interceptors/
│ │ └── audit-log.interceptor.ts
│ │
│ ├── config/
│ │ └── index.ts
│ ├── app.module.ts
│ └── main.ts
│
├── test/
│ ├── unit/
│ └── e2e/
│ └── backup-share.e2e-spec.ts
│
├── .env.example
├── .env.development
├── package.json
├── tsconfig.json
├── nest-cli.json
└── Dockerfile
3. 数据库设计
3.1 Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 备份分片存储
model BackupShare {
shareId BigInt @id @default(autoincrement()) @map("share_id")
// 用户标识 (来自 identity-service)
userId BigInt @unique @map("user_id")
accountSequence BigInt @unique @map("account_sequence")
// MPC 密钥信息
publicKey String @unique @map("public_key") @db.VarChar(130)
partyIndex Int @default(2) @map("party_index") // Backup = Party 2
threshold Int @default(2)
totalParties Int @default(3) @map("total_parties")
// 加密的分片数据 (AES-256-GCM 加密)
encryptedShareData String @map("encrypted_share_data") @db.Text
encryptionKeyId String @map("encryption_key_id") @db.VarChar(64) // 密钥轮换支持
// 状态管理
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, REVOKED, ROTATED
// 访问控制
accessCount Int @default(0) @map("access_count") // 访问次数限制
lastAccessedAt DateTime? @map("last_accessed_at")
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
revokedAt DateTime? @map("revoked_at")
// 索引
@@index([publicKey], name: "idx_backup_public_key")
@@index([status], name: "idx_backup_status")
@@index([createdAt], name: "idx_backup_created")
@@map("backup_shares")
}
// 访问审计日志
model ShareAccessLog {
logId BigInt @id @default(autoincrement()) @map("log_id")
shareId BigInt @map("share_id")
userId BigInt @map("user_id")
action String @db.VarChar(20) // STORE, RETRIEVE, REVOKE, ROTATE
sourceService String @map("source_service") @db.VarChar(50) // identity-service, recovery-service
sourceIp String @map("source_ip") @db.VarChar(45)
success Boolean @default(true)
errorMessage String? @map("error_message") @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([shareId], name: "idx_log_share")
@@index([userId], name: "idx_log_user")
@@index([action], name: "idx_log_action")
@@index([createdAt], name: "idx_log_created")
@@map("share_access_logs")
}
4. API 设计
4.1 存储备份分片
POST /backup-share/store
由 identity-service 在创建账户时调用。
// Request DTO
class StoreBackupShareDto {
@IsNotEmpty()
userId: string; // identity-service 的用户ID
@IsNotEmpty()
accountSequence: number; // 账户序列号
@IsNotEmpty()
publicKey: string; // MPC 公钥
@IsNotEmpty()
encryptedShareData: string; // 已加密的分片数据
@IsOptional()
threshold?: number; // 默认 2
@IsOptional()
totalParties?: number; // 默认 3
}
// Response
{
"success": true,
"shareId": "123",
"message": "Backup share stored successfully"
}
4.2 获取备份分片
POST /backup-share/retrieve
用于账户恢复场景,需要额外的身份验证。
// Request DTO
class RetrieveBackupShareDto {
@IsNotEmpty()
userId: string;
@IsNotEmpty()
publicKey: string;
@IsNotEmpty()
recoveryToken: string; // 恢复令牌 (由 identity-service 签发)
@IsOptional()
deviceId?: string; // 新设备ID
}
// Response
{
"success": true,
"encryptedShareData": "...",
"partyIndex": 2,
"publicKey": "..."
}
4.3 撤销分片
POST /backup-share/revoke
用于密钥轮换或账户注销。
// Request DTO
class RevokeShareDto {
@IsNotEmpty()
userId: string;
@IsNotEmpty()
publicKey: string;
@IsNotEmpty()
reason: string; // ROTATION, ACCOUNT_CLOSED, SECURITY_BREACH
}
5. 服务间认证
5.1 认证机制
backup-service 不对外公开,只接受来自内部服务的请求。使用 服务间 JWT 或 mTLS 进行认证。
// src/shared/guards/service-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class ServiceAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const serviceToken = request.headers['x-service-token'];
if (!serviceToken) {
throw new UnauthorizedException('Missing service token');
}
try {
const secret = this.configService.get<string>('SERVICE_JWT_SECRET');
const payload = jwt.verify(serviceToken, secret) as { service: string };
// 只允许特定服务访问
const allowedServices = ['identity-service', 'recovery-service'];
if (!allowedServices.includes(payload.service)) {
throw new UnauthorizedException('Service not authorized');
}
request.sourceService = payload.service;
return true;
} catch (error) {
throw new UnauthorizedException('Invalid service token');
}
}
}
5.2 identity-service 调用示例
在 identity-service 中添加调用 backup-service 的客户端:
// identity-service/src/infrastructure/external/backup/backup-client.service.ts
@Injectable()
export class BackupClientService {
private readonly backupServiceUrl: string;
private readonly serviceToken: string;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.backupServiceUrl = this.configService.get('BACKUP_SERVICE_URL');
this.serviceToken = this.generateServiceToken();
}
async storeBackupShare(params: {
userId: string;
accountSequence: number;
publicKey: string;
encryptedShareData: string;
}): Promise<void> {
await firstValueFrom(
this.httpService.post(
`${this.backupServiceUrl}/backup-share/store`,
params,
{
headers: {
'Content-Type': 'application/json',
'X-Service-Token': this.serviceToken,
},
},
),
);
}
private generateServiceToken(): string {
const secret = this.configService.get<string>('SERVICE_JWT_SECRET');
return jwt.sign(
{ service: 'identity-service', iat: Math.floor(Date.now() / 1000) },
secret,
{ expiresIn: '1h' },
);
}
}
6. 核心实现
6.1 领域实体
// src/domain/entities/backup-share.entity.ts
export class BackupShare {
private constructor(
private readonly _shareId: bigint | null,
private readonly _userId: bigint,
private readonly _accountSequence: bigint,
private readonly _publicKey: string,
private readonly _partyIndex: number,
private readonly _threshold: number,
private readonly _totalParties: number,
private _encryptedShareData: string,
private _encryptionKeyId: string,
private _status: BackupShareStatus,
private _accessCount: number,
private readonly _createdAt: Date,
) {}
static create(params: {
userId: bigint;
accountSequence: bigint;
publicKey: string;
encryptedShareData: string;
encryptionKeyId: string;
}): BackupShare {
return new BackupShare(
null,
params.userId,
params.accountSequence,
params.publicKey,
2, // Backup = Party 2
2, // threshold
3, // totalParties
params.encryptedShareData,
params.encryptionKeyId,
BackupShareStatus.ACTIVE,
0,
new Date(),
);
}
recordAccess(): void {
if (this._status !== BackupShareStatus.ACTIVE) {
throw new DomainError('Cannot access revoked share');
}
this._accessCount++;
}
revoke(reason: string): void {
if (this._status === BackupShareStatus.REVOKED) {
throw new DomainError('Share already revoked');
}
this._status = BackupShareStatus.REVOKED;
}
rotate(newEncryptedData: string, newKeyId: string): void {
this._encryptedShareData = newEncryptedData;
this._encryptionKeyId = newKeyId;
this._status = BackupShareStatus.ACTIVE;
}
// Getters...
get shareId(): bigint | null { return this._shareId; }
get userId(): bigint { return this._userId; }
get publicKey(): string { return this._publicKey; }
get encryptedShareData(): string { return this._encryptedShareData; }
get status(): BackupShareStatus { return this._status; }
get accessCount(): number { return this._accessCount; }
}
export enum BackupShareStatus {
ACTIVE = 'ACTIVE',
REVOKED = 'REVOKED',
ROTATED = 'ROTATED',
}
6.2 应用服务
// src/application/services/backup-share-application.service.ts
@Injectable()
export class BackupShareApplicationService {
private readonly logger = new Logger(BackupShareApplicationService.name);
constructor(
@Inject(BACKUP_SHARE_REPOSITORY)
private readonly repository: BackupShareRepository,
private readonly encryptionService: AesEncryptionService,
private readonly auditService: AuditLogService,
) {}
async storeBackupShare(command: StoreBackupShareCommand): Promise<string> {
this.logger.log(`Storing backup share for user: ${command.userId}`);
// 检查是否已存在
const existing = await this.repository.findByUserId(BigInt(command.userId));
if (existing) {
throw new ApplicationError('Backup share already exists for this user');
}
// 二次加密 (identity-service 已加密一次,这里再加密一次)
const { encrypted, keyId } = await this.encryptionService.encrypt(
command.encryptedShareData,
);
// 创建实体
const share = BackupShare.create({
userId: BigInt(command.userId),
accountSequence: BigInt(command.accountSequence),
publicKey: command.publicKey,
encryptedShareData: encrypted,
encryptionKeyId: keyId,
});
// 保存
const saved = await this.repository.save(share);
// 记录审计日志
await this.auditService.log({
shareId: saved.shareId!,
userId: BigInt(command.userId),
action: 'STORE',
sourceService: command.sourceService,
sourceIp: command.sourceIp,
success: true,
});
this.logger.log(`Backup share stored: shareId=${saved.shareId}`);
return saved.shareId!.toString();
}
async retrieveBackupShare(query: GetBackupShareQuery): Promise<BackupShareDto> {
this.logger.log(`Retrieving backup share for user: ${query.userId}`);
const share = await this.repository.findByUserIdAndPublicKey(
BigInt(query.userId),
query.publicKey,
);
if (!share) {
throw new ApplicationError('Backup share not found');
}
if (share.status !== BackupShareStatus.ACTIVE) {
throw new ApplicationError('Backup share is not active');
}
// 记录访问
share.recordAccess();
await this.repository.save(share);
// 解密
const decrypted = await this.encryptionService.decrypt(
share.encryptedShareData,
share.encryptionKeyId,
);
// 记录审计日志
await this.auditService.log({
shareId: share.shareId!,
userId: BigInt(query.userId),
action: 'RETRIEVE',
sourceService: query.sourceService,
sourceIp: query.sourceIp,
success: true,
});
return {
encryptedShareData: decrypted,
partyIndex: share.partyIndex,
publicKey: share.publicKey,
};
}
}
7. 环境配置
7.1 .env.example
# Database (必须是独立的数据库实例,不能与 identity-service 共享!)
DATABASE_URL="postgresql://postgres:password@backup-db-server:5432/rwa_backup?schema=public"
# Server
APP_PORT=3002
APP_ENV="development"
# Service Authentication
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
ALLOWED_SERVICES="identity-service,recovery-service"
# Encryption (用于二次加密备份分片)
BACKUP_ENCRYPTION_KEY="your-256-bit-encryption-key-in-hex"
BACKUP_ENCRYPTION_KEY_ID="key-v1"
# Rate Limiting
MAX_RETRIEVE_PER_DAY=3 # 每用户每天最多获取 3 次
MAX_STORE_PER_MINUTE=10 # 每分钟最多存储 10 个
# Audit
AUDIT_LOG_RETENTION_DAYS=365 # 审计日志保留 365 天
# Monitoring
PROMETHEUS_ENABLED=true
PROMETHEUS_PORT=9102
8. 部署要求
8.1 物理隔离
强制要求:
# docker-compose.yml (仅供参考架构,生产环境必须分开部署)
# ❌ 错误示例 - 同一台机器
services:
identity-service:
...
backup-service: # 不能在这里!
...
# ✅ 正确示例 - 分开部署
# 机器 A: docker-compose-main.yml
services:
identity-service:
...
# 机器 B: docker-compose-backup.yml (异地机房)
services:
backup-service:
...
8.2 网络安全
# backup-service 的网络策略
- 只允许来自 identity-service 的入站连接
- 禁止任何公网访问
- 使用 VPN 或专线连接主机房和备份机房
8.3 数据库安全
- 使用独立的 PostgreSQL 实例
- 启用 TLS 加密连接
- 定期备份到第三方存储
- 启用审计日志
9. 测试
9.1 E2E 测试
// test/e2e/backup-share.e2e-spec.ts
describe('BackupShare (e2e)', () => {
let app: INestApplication;
let serviceToken: string;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// 生成测试用的服务令牌
serviceToken = jwt.sign(
{ service: 'identity-service' },
process.env.SERVICE_JWT_SECRET,
);
});
describe('POST /backup-share/store', () => {
it('should store backup share successfully', async () => {
const response = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '12345',
accountSequence: 1001,
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-share-data-base64',
})
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.shareId).toBeDefined();
});
it('should reject duplicate share', async () => {
// First store
await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '99999',
accountSequence: 9999,
publicKey: '02' + 'b'.repeat(64),
encryptedShareData: 'data',
});
// Duplicate
await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '99999',
accountSequence: 9999,
publicKey: '02' + 'b'.repeat(64),
encryptedShareData: 'data',
})
.expect(400);
});
it('should reject unauthorized service', async () => {
await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', 'invalid-token')
.send({})
.expect(401);
});
});
describe('POST /backup-share/retrieve', () => {
it('should retrieve backup share with valid recovery token', async () => {
// Setup: store a share first
const storeResponse = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send({
userId: '77777',
accountSequence: 7777,
publicKey: '02' + 'c'.repeat(64),
encryptedShareData: 'test-encrypted-data',
});
// Retrieve
const response = await request(app.getHttpServer())
.post('/backup-share/retrieve')
.set('X-Service-Token', serviceToken)
.send({
userId: '77777',
publicKey: '02' + 'c'.repeat(64),
recoveryToken: 'valid-recovery-token',
})
.expect(200);
expect(response.body.encryptedShareData).toBeDefined();
expect(response.body.partyIndex).toBe(2);
});
});
});
10. 与 identity-service 的集成
10.1 修改 identity-service
在 identity-service 的 autoCreateAccount 中添加调用 backup-service:
// identity-service/src/application/services/user-application.service.ts
// 在保存服务端分片后,调用 backup-service 存储备份分片
await this.mpcKeyShareRepository.saveServerShare({...});
// 新增: 调用 backup-service 存储备份分片
if (mpcResult.backupShareData) {
try {
await this.backupClient.storeBackupShare({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
publicKey: mpcResult.publicKey,
encryptedShareData: mpcResult.backupShareData,
});
this.logger.log(`Backup share stored to backup-service for user: ${account.userId}`);
} catch (error) {
this.logger.error(`Failed to store backup share: ${error.message}`);
// 根据业务需求决定是否回滚或继续
// 建议: 记录失败,后续通过补偿任务重试
}
}
10.2 配置更新
在 identity-service 的 .env 中添加:
# Backup Service
BACKUP_SERVICE_URL="http://backup-server:3002"
SERVICE_JWT_SECRET="your-super-secret-service-jwt-key"
11. 监控和告警
11.1 关键指标
- backup_share_store_total # 存储总数
- backup_share_retrieve_total # 获取总数
- backup_share_revoke_total # 撤销总数
- backup_share_store_latency_ms # 存储延迟
- backup_share_retrieve_latency_ms # 获取延迟
- backup_share_error_total # 错误总数
11.2 告警规则
# 异常访问告警
- alert: HighBackupShareRetrieveRate
expr: rate(backup_share_retrieve_total[5m]) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "Backup share retrieve rate is high"
# 服务不可用告警
- alert: BackupServiceDown
expr: up{job="backup-service"} == 0
for: 1m
labels:
severity: critical
12. 开发步骤
12.1 初始化项目
# 1. 创建 NestJS 项目
cd backend/services/backup-service
npx @nestjs/cli new . --skip-git
# 2. 安装依赖
npm install @nestjs/config @prisma/client class-validator class-transformer
npm install -D prisma
# 3. 初始化 Prisma
npx prisma init
# 4. 复制 schema 并生成客户端
npx prisma generate
npx prisma migrate dev --name init
12.2 开发顺序
- 基础设施层 - Prisma 配置、数据库连接
- 领域层 - BackupShare 实体、Repository 接口
- 应用层 - 存储/获取/撤销命令处理
- 接口层 - REST API Controller
- 安全层 - ServiceAuthGuard、审计日志
- 测试 - 单元测试、E2E 测试
13. 检查清单
开发完成后,确保以下事项:
- 数据库部署在独立服务器
- 服务间认证已实现
- 分片数据已二次加密
- 审计日志已启用
- 访问频率限制已实现
- E2E 测试通过
- 与 identity-service 集成测试通过
- 监控指标已配置
- 告警规则已配置
- 生产环境部署在异地机房