news 2026/7/17 17:32:22

SOPS与AWS KMS策略条件:实现云原生秘密管理的精细化访问控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SOPS与AWS KMS策略条件:实现云原生秘密管理的精细化访问控制

1. 项目概述:为什么SOPS与AWS KMS的策略条件是安全命脉?

在云原生和DevOps的世界里,秘密管理(Secrets Management)一直是个让人头疼又必须严肃对待的问题。配置文件里明文写着的数据库密码、API密钥,就像把家门钥匙挂在门把手上。我见过太多团队因为一个.env文件误提交到Git仓库而引发的安全警报,甚至生产环境数据泄露。SOPS(Secrets OPerationS)的出现,为这个问题提供了一个优雅的解决方案:它允许你像管理普通代码一样管理加密后的秘密文件,但解密密钥由你掌控。而AWS KMS(Key Management Service)则是这套方案中,在AWS生态里最坚实、最可控的“钥匙保管员”。

但问题来了:仅仅把SOPS指向一个KMS密钥(CMK)就够了吗?远远不够。我见过不少配置,KMS密钥的策略(Policy)宽泛得吓人,比如允许某个IAM角色进行kms:Decrypt,却没有附加任何条件(Condition)。这就好比你把保险箱的密码告诉了同事,却没告诉他只能在公司内网、只能在上班时间使用。一旦这个IAM角色的凭证不慎泄露(比如误提交到代码库、被恶意软件窃取),攻击者就可以在任何地方、任何时间,用这个凭证解密你所有的秘密文件,后果不堪设想。

因此,这个“最安全实践”的核心,远不止于“能用”,而在于“如何安全地用”。它关乎如何利用AWS KMS策略中的Condition字段,为每一次解密操作戴上“紧箍咒”,实现最小权限原则和情景感知访问控制。这不仅是配置一个工具,更是构建一道精细的、动态的安全防线。无论你是运维工程师、安全工程师还是开发负责人,理解并实施这套指南,都能让你在享受SOPS便利的同时,将风险降到最低。

2. 核心安全理念与策略条件深度解析

2.1 最小权限原则:从“能解密”到“在什么情况下能解密”

AWS安全的基础是最小权限原则。对于KMS密钥策略,传统的做法可能是:

{ "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/MyCICDRole"}, "Action": "kms:Decrypt", "Resource": "*" }

这条策略简单粗暴地允许MyCICDRole角色解密任何用此密钥加密的数据。风险是显而易见的。我们的目标是将它升级为:

{ "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/MyCICDRole"}, "Action": "kms:Decrypt", "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:aws:cloudtrail:arn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/MySecurityTrail" } } }

这个Condition要求解密请求必须携带特定的加密上下文(Encryption Context),而这个上下文值关联到了你的CloudTrail审计日志。这意味着,解密操作被绑定到了一个可审计的、特定的上下文环境中。

注意:加密上下文是KMS加解密操作中一组键值对,它不会被加密,但会参与加密数据密钥的生成,并必须原样提供才能成功解密。它是实现情景化访问控制的绝佳工具。

2.2 策略条件(Condition)的关键元素剖析

KMS策略条件可以基于多种“条件键”(Condition Key)来构建。对于SOPS使用场景,以下几类最为关键:

  1. 加密上下文条件键 (kms:EncryptionContext:*):这是最强大、最常用的条件类型。SOPS在加密时,可以将环境、服务、项目等元数据作为加密上下文写入。解密时,KMS会校验请求中携带的加密上下文是否与策略条件匹配。

    • kms:EncryptionContext:environment:限制只有来自特定环境(如production,staging)的请求才能解密。
    • kms:EncryptionContext:service:限制只有特定服务(如payment-service,user-database)才能解密。
  2. 请求者身份条件键

    • aws:PrincipalArn:限制解密请求必须来自特定ARN的IAM实体(如特定角色)。这可以作为Principal的二次校验。
    • aws:SourceIdentity:在角色扮演(AssumeRole)会话中传递的标识,可用于追踪初始请求者。
  3. 网络位置条件键

    • aws:SourceVpc/aws:SourceVpce:限制解密请求必须来自指定的VPC或VPC端点。这是防止凭证在外部网络被滥用的关键防线。例如,只允许在部署了EC2实例或Fargate任务的私有子网VPC内解密。
    • aws:SourceIp:基于IP地址范围限制。但由于IP易变且在企业NAT环境下可能不准确,通常作为VPC条件的补充而非替代。
  4. 时间条件键(谨慎使用)

    • aws:CurrentTime/aws:EpochTime:可以限制解密操作只能在特定时间窗口内进行。这对于临时性任务或维护窗口有用,但需确保时间同步,且要考虑时区问题。

2.3 SOPS如何与KMS策略条件协同工作

理解SOPS的工作流程是配置策略条件的前提。当你执行sops --encrypt --kms “arn:aws:kms:...” config.yaml时:

  1. 加密阶段:SOPS会生成一个随机的数据加密密钥(DEK)来加密你的文件内容。然后,它调用KMS的EncryptAPI,使用你指定的CMK来加密这个DEK。关键一步:你可以通过--encryption-context参数(或在.sops.yaml配置文件中定义)传递一组键值对。这些上下文会被SOPS包含在Encrypt请求中,并和加密后的DEK一起保存在SOPS文件的开头元数据中。

    sops --encrypt \ --kms “arn:aws:kms:us-east-1:123456789012:key/your-key-id” \ --encryption-context “environment=prod,service=api” \ config.yaml > config.enc.yaml
  2. 解密阶段:当执行sops --decrypt config.enc.yaml时,SOPS会读取文件元数据中的KMS ARN和加密上下文,然后向KMS发起Decrypt请求,并将完全相同的加密上下文传回。

  3. 策略校验阶段:KMS在收到Decrypt请求后,会执行以下检查:

    • 调用者(IAM角色)是否有该CMK的kms:Decrypt权限?
    • 请求中携带的加密上下文,是否满足CMK策略中定义的Condition
    • 只有两者都满足,KMS才会解密数据密钥(DEK)并返回给SOPS,SOPS再用它解密文件内容。

实操心得:务必在加密阶段就规划好并记录下使用的加密上下文。因为解密时必须提供完全一致的上下文(键和值都区分大小写)。一个最佳实践是在项目根目录创建.sops.yaml配置文件,预定义不同环境的上下文,避免手动输入错误。

# .sops.yaml creation_rules: - path_regex: .*prod\.yaml$ kms: “arn:aws:kms:us-east-1:123456789012:key/your-key-id” encryption_context: environment: production project: “my-awesome-project” managed_by: “sops”

3. 分场景配置指南与实操演练

3.1 场景一:CI/CD流水线中的安全解密

这是SOPS最典型的应用场景。你的Git仓库里存放着加密后的配置文件,CI/CD任务(如GitHub Actions, GitLab CI, Jenkins)在部署时需要解密它们。

安全挑战:CI/CD运行器(Runner)通常以一个拥有较高权限的IAM角色运行。这个角色的凭证可能存在于 Runner 的环境变量中。我们需要确保,即使凭证泄露,解密也只能发生在合法的CI/CD环境里。

策略配置方案: 我们将结合aws:SourceVpc和加密上下文来锁定解密环境。

  1. 创建专用的CI/CD VPC和子网:为你的CI/CD运行器(如EC2实例、ECS任务)建立一个独立的私有子网。记下该VPC的ID(如vpc-0a1b2c3d4e5f67890)。

  2. 定义KMS密钥策略

    { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowCICDRoleDecryptOnlyFromSpecificVPCAndContext", "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/MyCICDRole"}, "Action": "kms:Decrypt", "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:ci_environment": "production-pipeline", "kms:EncryptionContext:git_repo": "my-org/my-service" }, "StringEqualsIfExists": { "aws:SourceVpc": "vpc-0a1b2c3d4e5f67890" } } } ] }
    • StringEqualsIfExists用于aws:SourceVpc是一个精妙的技巧。它表示“如果请求中包含了SourceVpc这个字段,那么它的值必须等于指定的VPC ID”。这确保了从互联网直接发起的请求(没有VPC上下文)会被拒绝,而从指定VPC内发起的请求则必须匹配。
  3. 在CI/CD脚本中配置SOPS

    # .github/workflows/deploy.yml 示例片段 jobs: deploy: runs-on: [self-hosted, linux, my-cicd-vpc-runner] # 确保Runner在指定VPC steps: - uses: actions/checkout@v4 - name: Decrypt secrets with SOPS run: | sops --decrypt \ --encryption-context “ci_environment=production-pipeline,git_repo=my-org/my-service” \ --output config.yaml \ config.enc.yaml env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-1

注意事项

  • 对于托管式CI/CD服务(如GitHub Hosted Runner),你无法控制其VPC。此时应放弃aws:SourceVpc条件,转而完全依赖加密上下文,并确保CI/CD工作流中的上下文值是高度机密且难以猜测的。可以考虑将部分上下文值也作为Secret存储在CI系统中,而非硬编码在配置文件里。
  • 定期轮换(Rotate)CI/CD角色使用的访问密钥。

3.2 场景二:多环境(开发/预发/生产)的秘密隔离

使用同一个KMS密钥来管理所有环境的秘密很方便,但需要严格隔离,防止开发环境误解密生产数据。

策略配置方案:利用加密上下文中的environment字段进行强制隔离。

  1. 统一的KMS密钥策略

    { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowDevRoleDecryptOnlyDevContext", "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/DevDeployRole"}, "Action": "kms:Decrypt", "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:environment": "development" } } }, { "Sid": "AllowProdRoleDecryptOnlyProdContext", "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/ProdDeployRole"}, "Action": "kms:Decrypt", "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:environment": "production" }, "IpAddress": { "aws:SourceIp": "10.0.1.0/24" // 生产环境的跳板机IP段 } } } ] }

    这里,生产环境的策略更加严格,叠加了IP限制。

  2. 环境特定的SOPS配置文件

    # .sops.yaml creation_rules: - path_regex: secrets/development/.* kms: “arn:aws:kms:us-east-1:123456789012:key/your-key-id” encryption_context: environment: development managed_by: “sops” - path_regex: secrets/production/.* kms: “arn:aws:kms:us-east-1:123456789012:key/your-key-id” encryption_context: environment: production managed_by: “sops”

    这样,在加密时就会自动应用正确的上下文。

实操心得:即使使用了上下文隔离,也强烈建议为生产环境使用独立的KMS CMK。这提供了真正的密钥级隔离,符合安全审计要求,并且在密钥轮换或事故响应时,可以独立操作而不影响其他环境。

3.3 场景三:混合云或本地开发环境

开发者需要在本地笔记本电脑上解密配置文件进行开发测试。这带来了最大的风险,因为笔记本电脑是最易失陷的环境。

安全策略绝不授予本地开发IAM用户直接的kms:Decrypt权限。取而代之的是采用临时安全凭证(STS)和强上下文绑定。

  1. 配置基于角色的访问

    • 创建一个专门用于本地解密的IAM角色(如LocalDevDecryptRole),其信任策略允许特定的IAM用户来扮演(AssumeRole)。
    • 为该角色配置极其严格的KMS策略,加密上下文必须包含开发者的标识和项目名。
    { "Sid": "AllowLocalDevRoleDecryptWithStrictContext", "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::123456789012:role/LocalDevDecryptRole"}, "Action": "kms:Decrypt", "Resource": "*", "Condition": { "StringEquals": { "kms:EncryptionContext:user": “{{aws:username}}”, // 动态替换为实际用户名 "kms:EncryptionContext:purpose": “local-development” } } }

    注意:KMS策略条件中不支持直接使用aws:username这样的变量替换。这是一个常见的误解。上述写法是无效的。正确的做法是在加密时,由开发者手动或通过脚本将用户名作为静态上下文值传入。这虽然增加了步骤,但确保了审计追踪。

  2. 本地开发流程

    • 开发者首先使用自己的长期凭证,调用sts:AssumeRole获取LocalDevDecryptRole的临时凭证。
    • 使用临时凭证配置AWS CLI/ SDK。
    • 执行SOPS解密命令,并明确指定加密上下文。
    # 1. 获取临时凭证 CREDS=$(aws sts assume-role --role-arn arn:aws:iam::123456789012:role/LocalDevDecryptRole --role-session-name “alice-local-dev”) export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .Credentials.AccessKeyId) export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .Credentials.SecretAccessKey) export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .Credentials.SessionToken) # 2. 使用带上下文的SOPS解密 sops --decrypt \ --encryption-context “user=alice,purpose=local-development,project=my-service” \ secrets/development/config.enc.yaml

重要提醒:务必教育开发者不要将临时凭证提交到代码或任何不安全的日志中。可以考虑使用aws-vaultleapp等工具来更安全地管理角色切换和临时凭证。

4. 高级策略与自动化配置

4.1 使用条件运算符构建复杂逻辑

AWS策略语言支持多种条件运算符,可以实现更精细的控制:

  • StringEquals/StringNotEquals:字符串完全等于/不等于。
  • StringLike/StringNotLike:通配符匹配(*?)。
  • ArnEquals/ArnLike:ARN匹配。
  • IpAddress/NotIpAddress:IP地址范围。
  • Bool:布尔值检查。
  • Null:检查键是否存在。

示例:允许来自一组特定VPC端点或特定IP的请求解密

"Condition": { "StringEquals": { "kms:EncryptionContext:environment": "staging" }, "ForAnyValue:StringEquals": { "aws:SourceVpce": ["vpce-111", "vpce-222"] } }

ForAnyValue:StringEquals表示请求中的aws:SourceVpce值(可能多个)只要与列表中任意一个匹配即可。

4.2 通过Infrastructure as Code (IaC) 管理策略

手动在AWS控制台编辑JSON策略容易出错且难以追踪。应使用IaC工具管理,如Terraform或AWS CloudFormation。

Terraform 示例

resource “aws_kms_key” “sops_master_key” { description = “Master key for SOPS encryption” enable_key_rotation = true policy = data.aws_iam_policy_document.sops_key_policy.json } data “aws_iam_policy_document” “sops_key_policy” { statement { sid = “EnableIAMRootPermissions” effect = “Allow” principals { type = “AWS” identifiers = [“arn:aws:iam::${data.aws_caller_identity.current.account_id}:root”] } actions = [“kms:*”] resources = [“*”] } statement { sid = “AllowCICDDecryptWithConditions” effect = “Allow” principals { type = “AWS” identifiers = [aws_iam_role.cicd_role.arn] } actions = [“kms:Decrypt”] resources = [“*”] condition { test = “StringEquals” variable = “kms:EncryptionContext:environment” values = [“production”] } condition { test = “StringEqualsIfExists” variable = “aws:SourceVpc” values = [aws_vpc.cicd_vpc.id] } } }

这样,策略的变更可以通过代码评审(Pull Request)来管控,并留有清晰的版本历史。

4.3 密钥策略与IAM策略的协同与边界

需要明确区分:

  • KMS密钥策略(Key Policy):附加在KMS CMK本身的资源策略。它是访问CMK的主要控制方式。对于自定义CMK,密钥策略必须显式允许所有访问,包括账户根用户。IAM策略本身不足以授予对CMK的访问权限,除非密钥策略也允许。
  • IAM策略(IAM Policy):附加在IAM用户、组或角色上的身份策略。它可以允许或拒绝针对KMS API的操作,但前提是密钥策略也允许该主体访问。

最佳实践:对于跨账户访问或需要将密钥管理委托给IAM管理员的情况,可以在密钥策略中授予IAM主体(如角色)管理权限,然后通过IAM策略来细化该主体下不同用户或角色的操作权限。但对于核心的解密权限控制,应尽可能在KMS密钥策略中通过Condition完成,因为这是最靠近资源的防线。

5. 审计、监控与故障排查

5.1 启用并利用AWS CloudTrail进行审计

所有KMS API调用都会被记录到CloudTrail中。这是你审计“谁在何时何地解密了什么”的生命线。

  1. 确保CloudTrail日志已启用并加密
  2. 在CloudTrail事件中查看解密操作:事件中会包含:
    • eventSourcekms.amazonaws.com
    • eventNameDecrypt
    • userIdentity:调用者ARN。
    • requestParameters:包含encryptionContext!这是验证策略条件是否生效的关键。
    • sourceIPAddress/vpcEndpointId:请求来源。
    • errorCode:如果解密失败(如条件不满足),这里会记录原因(如AccessDenied)。

你可以配置CloudTrail日志发送到Amazon S3并用Athena查询,或发送到CloudWatch Logs设置告警。

示例Athena查询(查找特定时间的解密失败)

SELECT eventTime, userIdentity.arn, requestParameters.encryptionContext, sourceIPAddress, errorCode, errorMessage FROM cloudtrail_logs WHERE eventSource = ‘kms.amazonaws.com’ AND eventName = ‘Decrypt’ AND errorCode IS NOT NULL AND eventTime >= ‘2023-10-01T00:00:00Z’

5.2 常见解密失败原因与排查清单

sops --decrypt失败并提示KMS访问被拒绝时,请按以下顺序排查:

问题现象可能原因排查步骤
AccessDeniedException通用错误1. IAM主体无kms:Decrypt权限。
2. 密钥策略中未允许该主体。
1. 检查IAM角色的策略是否包含kms:Decrypt(对特定密钥资源)。
2.重点:检查KMS密钥策略,确认该主体的ARN在Principal字段中被允许Decrypt动作。
解密失败,但IAM策略看似正确加密上下文不匹配或缺失。1. 使用sops -d —-output-mode json yourfile.enc.yaml查看加密文件元数据中记录的encryption_context
2. 对比解密命令中或.sops.yaml配置里指定的上下文是否完全一致(键值对、大小写)。
3. 检查KMS密钥策略中的Condition块,确认要求的上下文键值对。
在CI/CD中成功,本地失败(或反之)网络位置条件(如aws:SourceVpc)限制。1. 检查失败环境的请求来源。对于本地,aws:SourceVpc通常不存在,如果策略使用StringEqualsIfExists且要求特定VPC,则会被拒绝。
2. 确认CI/CD Runner是否运行在策略允许的VPC内。
使用临时凭证失败临时凭证过期,或扮演角色后的主体ARN与策略中配置的Principal不匹配。1. 运行aws sts get-caller-identity确认当前生效的ARN。
2. 检查该ARN是否已被密钥策略允许。注意,扮演角色后,主体ARN是角色的ARN,而非原始用户的ARN。
跨账户访问失败密钥策略未允许外部账户,或外部账户的IAM角色未在密钥策略的Principal中列出。1. 在密钥策略中,外部账户的Principal格式为“AWS”: “arn:aws:iam::EXTERNAL_ACCOUNT_ID:root”或具体角色ARN。
2. 确保外部账户的角色信任关系允许本账户扮演。

一个实用的调试命令:在具有足够权限的情况下,可以使用AWS CLI模拟解密请求,这有助于隔离SOPS工具本身的问题:

# 首先,从SOPS文件中提取出加密的数据密钥(密文CiphertextBlob) # 假设你的SOPS文件是YAML格式,密文可能在 `sops.kms[0].enc` 字段(Base64编码) # 你需要先将其解码并保存为二进制文件,这里假设已保存为 `ciphertext_blob.bin` aws kms decrypt \ --key-id “arn:aws:kms:us-east-1:123456789012:key/your-key-id” \ --ciphertext-blob fileb://ciphertext_blob.bin \ --encryption-context “environment=production,service=api” \ --query Plaintext --output text | base64 -d

如果这个命令成功返回明文数据密钥,说明KMS层面是通的,问题可能出在SOPS读取文件或组装请求上。如果失败,AWS CLI会返回更详细的错误信息,直接指向策略或上下文问题。

5.3 密钥轮换与策略更新策略

  1. 启用自动密钥轮换:在KMS控制台或IaC中为CMK启用每年一次的自动密钥轮换。这不会轮换已加密的数据,而是为CMK创建新的后备密钥材料,新的加密操作将使用新版本材料。这符合很多合规要求。
  2. 策略更新流程:修改KMS密钥策略是高风险操作。务必遵循:
    • 在非高峰时段进行
    • 使用IaC,并通过Pull Request流程评审
    • 先应用“允许”新策略,再移除旧策略:避免在更新过程中产生权限真空。可以先将新策略语句作为独立Statement添加,验证无误后,再删除旧的语句。
    • 更新后立即测试:使用一个测试角色和测试文件,验证新策略下的解密操作是否按预期工作。

我个人在多个大型项目中推行这套“条件化”的SOPS+KMS实践后,最大的体会是:安全不是一蹴而就的开关,而是一系列深思熟虑的约束和持续的验证。最初配置策略条件可能会觉得繁琐,但一旦形成习惯和模板,它带来的安心感是无可替代的。每次在CloudTrail里看到那些带着正确加密上下文的解密记录,就像看到一道道门锁被正确钥匙在正确时间打开一样,你知道你的秘密处在可控的保护之下。最后一个小技巧是,为你的KMS密钥打上标签,比如UsedBy=SOPS,Environment=Production,并在密钥描述里清晰写明用途和主要限制条件,这对于团队协作和日后维护至关重要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/17 17:32:12

J-space:揭秘大语言模型内部推理的全局工作空间

1. 先搞清楚J-space到底是什么,以及它为什么值得关注如果你用过Claude这类大语言模型,可能会发现一个现象:有时候模型明明在内部进行了复杂的推理,但最终输出却只呈现了结果。比如你问“蜘蛛织网的动物有几条腿”,它直…

作者头像 李华
网站建设 2026/7/17 17:29:10

RISC-V MCU实现SPI接口TF卡FATFS文件系统

1. 项目背景与核心需求在嵌入式系统开发中,数据存储是一个永恒的话题。最近我在一个基于RISC-V架构的CH32V208项目中遇到了一个典型需求:需要通过SPI接口外接TF卡,并实现FATFS文件系统的读写功能。这个需求看似简单,但实际操作中却…

作者头像 李华
网站建设 2026/7/17 17:27:32

设计 Token 的语义化版本控制:如何让组件库平滑升级而不破坏页面

设计 Token 的语义化版本控制:如何让组件库平滑升级而不破坏页面 一、改了 Token 值,二十个页面变色——但没人知道 产品说"品牌主色要从 #1890FF 改成 #1677FF"。听起来很简单:改一个 CSS 变量。但两周后,用户反馈&quo…

作者头像 李华
网站建设 2026/7/17 17:24:09

3.5寸串口屏在环境检测仪中的驱动方案与UI设计实战

如果你正在开发环境检测设备,面对3.5寸彩屏的显示驱动方案选择,是否曾纠结于传统MCU直接驱动的高开发成本,或是担心串口屏的性能瓶颈?实际上,串口屏方案正在成为中小型环境检测设备的首选,但关键在于选对驱…

作者头像 李华
网站建设 2026/7/17 17:24:09

CANN/asc-devkit:ilogbf函数文档

ilogbf 【免费下载链接】asc-devkit 本项目是CANN 推出的昇腾AI处理器专用的算子程序开发语言,原生支持C和C标准规范,主要由类库和语言扩展层构成,提供多层级API,满足多维场景算子开发诉求。 项目地址: https://gitcode.com/can…

作者头像 李华