rwadurian/backend/services/backup-service/IMPLEMENTATION_GUIDE.md

25 KiB
Raw Blame History

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 不对外公开,只接受来自内部服务的请求。使用 服务间 JWTmTLS 进行认证。

// 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 开发顺序

  1. 基础设施层 - Prisma 配置、数据库连接
  2. 领域层 - BackupShare 实体、Repository 接口
  3. 应用层 - 存储/获取/撤销命令处理
  4. 接口层 - REST API Controller
  5. 安全层 - ServiceAuthGuard、审计日志
  6. 测试 - 单元测试、E2E 测试

13. 检查清单

开发完成后,确保以下事项:

  • 数据库部署在独立服务器
  • 服务间认证已实现
  • 分片数据已二次加密
  • 审计日志已启用
  • 访问频率限制已实现
  • E2E 测试通过
  • 与 identity-service 集成测试通过
  • 监控指标已配置
  • 告警规则已配置
  • 生产环境部署在异地机房