Skip to content

fix(kms-api, kms-core, kms-keystore): P0-1 nonce panic, P0-2 service-layer tenant pre-check, P0-3 keystore-layer tenant defence in depth - #42

Open
EricZHANG1688 wants to merge 21 commits into
mainfrom
release/0.3.0
Open

EricZHANG1688 wants to merge 21 commits into
mainfrom
release/0.3.0

Conversation

@EricZHANG1688

@EricZHANG1688 EricZHANG1688 commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR bundles three P0 security fixes from the 2026-09-22 source-level review of gm and gm-kms. Each fix is one atomic commit; all target release/0.3.0 → main.

  • P0-1: Fix AES-256-GCM decrypt 12-byte nonce out-of-bounds panic (DoS hardening)
  • P0-2: Pre-check tenant ownership before crypto operations; close key-enumeration oracle (service layer)
  • P0-3: Enforce tenant ownership at the keystore layer (defence in depth — any direct caller is now protected)
Fix Component API impact Tests
P0-1 gm-kms/crates/kms-core none 4 new regression tests
P0-2 gm-kms/crates/kms-api cross-tenant HTTP 403 → 404 (response shape: {"error":"key not found"}, no body change) 11 new regression tests
P0-3 gm-kms/crates/kms-keystore none (trait signature unchanged; behaviour change is internal) 12 new regression tests

All fixes are backward-compatible at the wire format / public API level.


P0-1: AES-256-GCM 12-byte nonce panic

Aes256GcmDecryptor::decrypt reconstructed the 12-byte AES-GCM nonce from the stored ciphertext nonce with an out-of-bounds slice index that panicked when ciphertext.nonce.len() was in the range [12, 16). Self-produced ciphertexts store a 16-byte counter so the panic was never triggered internally, but any externally imported / legacy / cross-implementation ciphertext with the standard 12-byte AES-GCM nonce (RFC 5116 §5.2 fixes N_MIN = N_MAX = 12 octets for AES-256-GCM) would crash the KMS process.

Replaced the if-else chain with an exhaustive match accepting both forms (12-byte direct, 16-byte counter via [4..16]), returning DecryptionFailed for any other length instead of panicking.

Tests: test_aes256gcm_decrypt_12byte_nonce_no_panic, test_aes256gcm_decrypt_truncated_native_nonce_to_12bytes_succeeds, test_aes256gcm_decrypt_invalid_nonce_length_{8,15}_returns_error.

Commit: 6b6fd56 (+ b5e0118 for clippy nit).


P0-2: Tenant pre-check + error-code unification (service layer)

CryptoService::encrypt/decrypt/sign/verify called the keystore FIRST and only verified tenant ownership AFTERWARDS. Three concrete harms:

  • HSM/TPM signing quota consumed by unauthorised requests
  • Timing distinguishable: cross-tenant requests pay full crypto cost, non-existent keys return immediately
  • Key-enumeration oracle: HTTP 404 KeyNotFound vs HTTP 403 Forbidden were distinguishable to the client, leaking which key_ids exist in the system

Fix: introduce private fetch_owned_key_meta helper that returns KeyNotFound for both "missing" and "wrong tenant" (with server-side tracing::warn! for SOC visibility). The pre-check happens BEFORE any cryptographic operation. The Forbidden variant remains available for PBAC policy-denial scenarios.

For consistency, KeyService::rotate_key, delete_key, get_key, export_key also switch their cross-tenant error from Forbidden to KeyNotFound (they already did the pre-check correctly; only the error code was wrong).

Tests (all pass with cargo +1.88 test --workspace --lib):

  • CryptoService:
    • test_pr12_{encrypt,decrypt,sign,verify}_cross_tenant_returns_keynotfound
    • test_pr12_sign_nonexistent_key_returns_keynotfound
    • test_pr12_cross_tenant_and_nonexistent_have_identical_responses — bytes match via axum to_bytes
    • test_pr12_sign_does_not_call_keystore_on_tenant_mismatch — uses a custom SignCountingKeystore wrapper; cross-tenant shows 0 sign invocations
  • KeyService:
    • test_pr12_key_{get,rotate,delete}_cross_tenant_returns_keynotfound
    • test_pr12_key_get_nonexistent_returns_keynotfound

Commit: c66a127.


P0-3: Keystore-layer tenant enforcement (defence in depth)

The KeystoreBackend trait itself accepts a tenant_id: &str argument on every sensitive method (encrypt, decrypt, sign, verify, rotate_key, delete_key, export_key_material, get_key_material, get_key_material_version), but every implementation received it as _tenant_id (underscore prefix) and ignored it. P0-2 only protects callers that go through the service layer — a new REST/gRPC handler, a CLI tool, an internal task, an ops script that called the keystore directly would still bypass the tenant check.

Fix: a private verify_tenant(key_id, tenant_id) -> Result<()> helper in SoftwareKeystore and PostgresKeystore reads metadata, compares meta.tenant_id against the caller-supplied tenant_id, and returns KeyNotFound on miss or mismatch (conflating the two outcomes, mirroring P0-2's service-layer behaviour). Every sensitive method calls verify_tenant as its first line. Parameter _tenant_id is renamed to tenant_id to signal that it is now actually consumed.

Out of scope (deferred): generate_key / import_key_material — tenant_id is the creator/importer, nothing to check. destroy_key / destroy_key_with_proof / derive_shared_secret / SM2-KEX session helpers — their trait signatures do not currently include tenant_id; extending the trait is a separate concern.

Tests (cargo +1.88 test -p kms-keystore --lib, 86 passed / 13 ignored):

Cross-tenant → KeyNotFound:

  • test_pr13_sign_cross_tenant_returns_keynotfound
  • test_pr13_decrypt_cross_tenant_returns_keynotfound
  • test_pr13_encrypt_cross_tenant_returns_keynotfound
  • test_pr13_verify_cross_tenant_returns_keynotfound
  • test_pr13_export_key_material_cross_tenant_returns_keynotfound
  • test_pr13_get_key_material_cross_tenant_returns_keynotfound
  • test_pr13_get_key_material_version_cross_tenant_returns_keynotfound
  • test_pr13_rotate_key_cross_tenant_returns_keynotfound
  • test_pr13_delete_key_cross_tenant_returns_keynotfound

Forward path (correct tenant still succeeds):

  • test_pr13_sign_correct_tenant_succeeds (Ed25519)
  • test_pr13_sm2_sign_correct_tenant_succeeds (SM2)
  • test_pr13_encrypt_decrypt_correct_tenant_succeeds (AES-256-GCM)

Commit: 7cf4070.


Verification

Check Tool Result
Format cargo +1.88 fmt --all -- --check ✅ clean
Lint cargo +1.88 clippy --workspace --all-targets -- -D warnings ✅ clean
Tests cargo +1.88 test --workspace ✅ 998 passed; 0 failed; 25 ignored
CryptoService cargo +1.88 test -p kms-api --lib service::crypto_service::tests:: ✅ all pass
KeyService cargo +1.88 test -p kms-api --lib service::key_service::tests:: ✅ all pass
Keystore cargo +1.88 test -p kms-keystore --lib ✅ 86 passed / 13 ignored (74 existing + 12 new)
Existing fail-secure tests fail_secure_tests.rs ✅ all pass (not regressed)

Files changed

  • crates/kms-core/src/algorithms_impl.rs (PR-1.1 fix + 4 tests)
  • crates/kms-api/src/service/crypto_service.rs (PR-1.2 fix + helper + 7 tests)
  • crates/kms-api/src/service/key_service.rs (PR-1.2 error-code unification + 4 tests)
  • crates/kms-keystore/src/software/mod.rs (PR-1.3 fix + verify_tenant helper)
  • crates/kms-keystore/src/postgres.rs (PR-1.3 fix + verify_tenant helper)
  • crates/kms-keystore/src/software/tests.rs (PR-1.3 tests, 12 new)
  • CHANGELOG.md (entries for P0-1, P0-2, P0-3)
  • README.md / README.en.md (test-count refresh)

Standards

  • GB/T 32918-2016 §5 (SM2 range check) — N/A here
  • GB/T 25056-2018 §7.4 (CRL handling) — N/A here
  • JR/T 0027-2020 §6.4 (multi-tenant isolation) — P0-2 + P0-3 strengthen compliance
  • GB/T 39786-2021 §4.4.3 (key material binding) — N/A here

Closes P0-1, P0-2 and P0-3 of the 2026-09-22 source-level review.

gm-kms agent added 6 commits September 12, 2026 17:46
This release combines two related work streams into a single 0.3.0
release. The commit can be split via 'git rebase -i HEAD~1' if the
team prefers two separate PRs (see SPLIT NOTE below).

================================================================
PR #1: Reposition as Rust reference implementation
================================================================

Goal: stop claiming 'production-ready KMS', clearly state what the
project is and isn't, and demote deployment assets that implied
production-readiness without delivering it.

- README.md / README.en.md: rewrite with positioning declaration,
  what-this-is / what-this-isn't matrix, compliance self-assessment
  table, applicable / non-applicable scenarios
- LEARN.md: new (221 lines), 10-node code walk-through for learners
- CHANGELOG.md: add 0.3.0 entry documenting the positioning shift
- deploy/kubernetes/: delete (4 files); move basic manifest to
  examples/k8s-demo/ with explicit 'DEMO ONLY — NOT PRODUCTION' framing
- operators/kms-operator/README.md: 'EXPERIMENTAL — NOT MAINTAINED' warning
- providers/terraform/README.md: 'EXPERIMENTAL — REFERENCE ONLY' warning
- Test counts table corrected to actual (877 → 972 with TLCP additions)

================================================================
PR #2: TLCP (GB/T 38636-2020) REST listener
================================================================

Goal: actually wire gm-tlcp into the REST API, so the project no
longer claims TLCP support that doesn't exist.

- src/cmd/tlcp_listener.rs: new axum Listener implementing TLCP dual-cert
  ECDHE handshake via gm_tlcp::TlcpAcceptor (269 lines + tests)
- src/cmd/config.rs: extend RestTlsConfig with tlcp_{sign,enc}_{cert,key}_path
  fields + validate_tlcp_paths() / to_tlcp_acceptor() helpers;
  wire env var support (REST_TLS_TLCP_*)
- src/cmd/server.rs: dispatch backend = 'tlcp' branch; surface config +
  load errors via tracing::error before returning
- src/cmd/mod.rs: register tlcp_listener module
- kms.toml.example: document [rest_tls].backend = 'tlcp' with TLCP fields
- Cargo.toml: add gm-tlcp = '0.6' dep; bump [patch.crates-io] rev to
  a7fdf20 (gm-tlcp-v0.6.4 tag) covering gm-ca / gm-crypto / gm-sm9-rs /
  gm-tlcp / gm-tls together; bump gm-crypto = '0.3' so the patch actually
  applies (gm-tls 0.2.0's exact dep on gm-crypto 0.2.0 would otherwise
  bypass the patch and create a dual-source E0308 error)
- docs/guides/tlcp-deployment.md: new (271 lines, bilingual), dual-cert
  generation, kms.toml examples, client verification, known limitations
- docs/wiki/tlcp.md: new (164 lines, bilingual), TLCP vs TLS 1.3 protocol
  comparison, ALPN gap explanation, 4 cipher suites
- Tests: 13 new unit tests across tlcp_listener.rs and config.rs
  covering config validation, cert load error paths, listener trait
  compatibility

Bug fixes caught during self-review (in PR #2 portion):
- Pre-existing double-bind bug in src/cmd/server.rs (plain TCP listener
  bound rest_addr unconditionally, TLS branches then EADDRINUSE-panic).
  Fixed by moving the plain listener creation into the _ => arm only.
- TLCP startup errors were silently swallowed by spawn'd task return value
  (the JoinHandle was never awaited). Fixed by logging via tracing::error
  before returning the error from the spawn'd async block.

================================================================
Verification
================================================================

- cargo fmt --all -- --check:        pass
- cargo check --workspace:           pass
- cargo clippy --workspace -- -D warnings: pass
- cargo test --workspace:            972 passed, 21 ignored, 0 failed
- TLS 1.3 + SM end-to-end startup:   pass (REST + gRPC both listen)
- TLCP startup with bad PEM:         pass (clear error logged:
  'TLCP startup aborted: failed to load dual-certs from ...')

================================================================
SPLIT NOTE
================================================================

To split this commit into two PRs:

  git reset --soft HEAD~1
  git restore --staged <files>
  git commit -m 'docs: reposition as Rust reference implementation'
  git commit -m 'feat: wire TLCP (GB/T 38636-2020) into REST listener'

Suggested file groupings for the split:

  PR #1 (docs + assets):
    CHANGELOG.md (manually pre-TLCP section)
    README.md, README.en.md, LEARN.md
    deploy/kubernetes/* (delete), examples/k8s-demo/* (add)
    operators/kms-operator/README.md, providers/terraform/README.md

  PR #2 (TLCP feature):
    src/cmd/tlcp_listener.rs (new)
    src/cmd/mod.rs
    src/cmd/server.rs (TLCP branch + double-bind fix + error logging)
    src/cmd/config.rs (TLCP fields + helpers)
    Cargo.toml, Cargo.lock
    kms.toml.example (TLCP portion of [rest_tls] block)
    docs/guides/tlcp-deployment.md, docs/wiki/tlcp.md

================================================================
Known limitations (documented in README/CHANGELOG/wiki)
================================================================

- gRPC over TLCP not implemented (TLCP protocol has no ALPN, gRPC needs h2)
- TLCP cert chain verification not integrated (waits for upstream
  gm-tlcp TlcpCertPair to wire cert_verify)
- TLCP mTLS (client certs) not implemented yet
- SM9 master key still in-memory by default (kms-hsm is a stub)
- No bug bounty / commercial support / SLA
- 0.3.0 is NOT a 1.0 release candidate
Bumps the [patch.crates-io] pins for the five gm-* crates
(gm-ca, gm-crypto, gm-sm9-rs, gm-tlcp, gm-tls) from rev
a7fdf20 to rev c77f0d7 — the commit that ships the
2026-09-18 cert-verification audit fixes:

  NEW-1 gm-ca KeyUsage BIT STRING wrapper (KU extnValue now
         parses cleanly via x509-parser 0.16)
  NEW-2 gm-crypto verify_against_anchors no longer hardcodes
         CertRole::Ca on single-element chains (legitimate TLCP
         leaf certs no longer get spuriously rejected)
  NEW-3 gm-ca / gm-crypto / gm-tls SM2 OID byte sequences
         corrected to the canonical GM/T 38636-2020 §6.4.6
         encoding (1.2.156.10197.1.{501,301,106}). Openssl
         now parses gm-ca-issued certs as
         'Signature Algorithm: SM2-with-SM3' /
         'Public Key Algorithm: sm2'.
  NEW-4 gm-tlcp test-stale-comment cleanup

Also bumps the gm-ca dev-dependency version constraint from '0.1.0'
to '0.2' so the patch takes effect (the previous version
constraint didn't match the 0.2.x patch).

examples/generate_sm2_certs.rs: updates to the gm-ca 0.2 API
surface (the old sign_csr was replaced by sign_csr_with_profile
which returns (serial_hex, pem_str) instead of (pem, serial)).
Also fixes the inline SM2_PK_OID_BYTES constant to the canonical
DER encoding (the previous non-canonical bytes would have made
the CSR's SPKI OID unparseable by openssl).
examples/generate_sm2_certs.rs's sign_certs comment said the
profile "matches gm-ca 0.1.x's sign_csr behavior" — process info
about which old API the profile replaced. The technical content
(what extensions the default end-entity profile emits) stays.

Doc-only commit. No behavior change.
The Aes256GcmDecryptor::decrypt reconstruction of the 12-byte AES-GCM
nonce from the stored ciphertext nonce had an out-of-bounds slice index
that panicked when ciphertext.nonce.len() was in the range [12, 16).
Self-produced ciphertexts store a 16-byte counter so the panic was never
triggered internally, but any externally imported / legacy /
cross-implementation ciphertext with the standard 12-byte AES-GCM nonce
(RFC 5116 §5.2 fixes AES-256-GCM nonce length to N_MIN = N_MAX = 12
octets) would crash the KMS process (DoS).

Replace the if-else chain with an exhaustive match that accepts the
12-byte direct form, the 16-byte self-produced counter form, and
returns DecryptionFailed for any other length instead of panicking.

Adds four regression tests covering:
- 12-byte external nonce decryption (the panic case)
- 16-byte self-produced nonce truncated to 12 bytes (proves both
  accepted forms are equivalent)
- nonces of length 8 and 15 (both must return DecryptionFailed)

Public API, Ciphertext layout, and Error variants are unchanged.
Wire format is unchanged for self-produced ciphertexts.

Resolves P0-1 from the source-level review (2026-09-22).
…ormat_args

The CI clippy gate runs `cargo clippy --workspace --all-targets -- -D warnings`,
which denies clippy::uninlined_format_args. Use the inline argument form
(`{other}`) instead of positional (`{}`, other).

Verified with stable 1.88 (matches CI toolchain):
- cargo +1.88 clippy --workspace --all-targets -- -D warnings  -> clean
- cargo +1.88 fmt --all -- --check                            -> clean
- cargo +1.88 test -p kms-core --lib algorithms_impl::tests::  -> 9 passed

Folding into the previous commit keeps history linear; PR #42 will
auto-update on push.
…or codes

The crypto service ran the keystore operation FIRST and only verified
tenant ownership AFTERWARDS. On a cross-tenant request, this leaked a
key-enumeration oracle (404 KeyNotFound vs 403 Forbidden are
distinguishable to the client) and consumed real HSM/TPM signing
quotas on requests the caller was not authorised to make.

Reorder the four crypto paths (encrypt, decrypt, sign, verify) in
CryptoService to use a new `fetch_owned_key_meta` helper that:

- returns KeyNotFound if the key does not exist
- returns KeyNotFound if the key exists but is owned by another tenant
- logs a server-side warn!() with requester / owner tenant IDs

The two failure modes are deliberately conflated to a single
KeyNotFound response (HTTP 404 "key not found", key_id not in body) so
they are indistinguishable from outside. The pre-check happens BEFORE
any cryptographic operation, so a cross-tenant request never spends
a signing/decryption/encryption budget.

Same pattern (already correct in spirit) is applied to KeyService
for consistency: rotate_key, delete_key, get_key, export_key switch
their cross-tenant error from Forbidden to KeyNotFound.

The post-process API for encoding API metrics (algorithm-aware) is
preserved by passing the `meta` obtained in the pre-check down to the
metric recording step.

Eleven new tests cover:

- CryptoService: encrypt/decrypt/sign/verify cross-tenant
  -> KeyNotFound; non-existent key -> KeyNotFound;
  cross-tenant and non-existent responses are byte-identical
  via IntoResponse; keystore.sign is NOT called on cross-tenant
  (counted via a SignCountingKeystore wrapper).
- KeyService: get_key / rotate_key / delete_key cross-tenant
  -> KeyNotFound; non-existent key -> KeyNotFound.

Forbidden remains available for PBAC / policy-denial scenarios
that are not about cross-tenant access.

Resolves P0-2 from the source-level review (2026-09-22).
@EricZHANG1688 EricZHANG1688 changed the title fix(kms-core): prevent panic in AES-256-GCM decrypt on 12-byte nonce fix(kms-api, kms-core): tenant pre-check + nonce panic (P0-1, P0-2) Sep 22, 2026
…fence in depth)

PR-1.2 added a pre-check in CryptoService / KeyService so that a
cross-tenant request returns KeyNotFound before any cryptographic
operation runs. That fix only protects callers that go through the
service layer.

The KeystoreBackend trait itself accepts a `tenant_id: &str` argument
on every sensitive method, but every implementation received it as
`_tenant_id` (underscore prefix) and ignored it. Any direct call to
the keystore — a new REST/gRPC handler, a CLI tool, an internal task,
an ops script — therefore bypassed the tenant check.

This change makes the keystore itself enforce ownership, mirroring
PR-1.2's conflation: a key that does not exist AND a key that belongs
to a different tenant both return `Error::KeyNotFound(key_id)`.

Concretely:

- New private helper `verify_tenant(key_id, tenant_id) -> Result<()>`
  in `SoftwareKeystore` and `PostgresKeystore`. Reads metadata, compares
  `meta.tenant_id` against the caller-supplied `tenant_id`, and returns
  `KeyNotFound` on miss or mismatch.

- All nine sensitive methods now invoke `verify_tenant` as their first
  line: `encrypt`, `decrypt`, `sign`, `verify`, `rotate_key`,
  `delete_key`, `export_key_material`, `get_key_material`,
  `get_key_material_version`. Parameter `_tenant_id` is renamed to
  `tenant_id` to signal that it is now actually consumed.

- `generate_key` / `import_key_material` are intentionally NOT changed:
  the tenant_id is the creator/importer, so there is nothing to check.

- `destroy_key` / `destroy_key_with_proof` / `derive_shared_secret` /
  the SM2-KEX session helpers are NOT changed: their trait signatures
  do not currently include `tenant_id`. Extending the trait to cover
  them is deferred (out of scope for PR-1.3).

- Twelve new tests cover the new contract end-to-end on the software
  backend: nine cross-tenant attempts each return KeyNotFound
  (sign / decrypt / encrypt / verify / export_key_material /
  get_key_material / get_key_material_version / rotate_key /
  delete_key), plus three forward-path tests confirming that the
  correct tenant still succeeds for Ed25519 sign/verify,
  SM2 sign/verify and AES-GCM encrypt/decrypt.

Postgres backend's behaviour is identical by construction (the same
`verify_tenant` helper is used); its integration tests remain
`#[ignore]`'d as before since they require a live database.

Verification (Rust 1.88 stable):

- `cargo +1.88 fmt --all -- --check`        clean
- `cargo +1.88 clippy --workspace --all-targets -- -D warnings`  clean
- `cargo +1.88 test --workspace`            998 passed, 25 ignored
- `cargo +1.88 test -p kms-keystore --lib`  86 passed, 13 ignored
  (74 existing + 12 new PR-1.3 tests)
- `cargo +1.88 test -p kms-api --lib`       267 passed, 5 ignored
  (PR-1.2 tests still pass — PR-1.3 is transparent to the
  service layer)

Resolves P0-3 from the source-level review (2026-09-22).
@EricZHANG1688 EricZHANG1688 changed the title fix(kms-api, kms-core): tenant pre-check + nonce panic (P0-1, P0-2) fix(kms-api, kms-core, kms-keystore): P0-1 nonce panic, P0-2 service-layer tenant pre-check, P0-3 keystore-layer tenant defence in depth Sep 22, 2026
…id, tenant_id, version)

PR-1.4 / P0-5. Bind AAD to (key_id, tenant_id, version) so that any tamper
with a persisted ciphertext row — swapping key_id, version, or tenant
context — causes the GCM tag check to fail.

New kms-core/src/aad.rs (Purpose enum, 42-byte v2 wire format with magic
marker + aad_version + purpose tag, three builders: user_data_aad /
kek_wrap_aad / export_wrap_aad). 9 aad.rs unit tests cover determinism,
collision resistance, and purpose-tag separation.

Wire format back-compat: Ciphertext::format_version ∈ {0, 1} keeps the
legacy empty-AAD branch; format_version == 2 uses the bound AAD; unknown
format_version returns Error::InvalidCiphertext (no panic).

Five call sites updated:
- software/mod.rs::encrypt / decrypt (AES-256-GCM + SM4-GCM branches) use
  user_data_aad(*key_id, &entry.meta.tenant_id, entry.meta.version /
  ciphertext.version). encrypt emits format_version = 2.
- postgres.rs::encrypt_material / decrypt_material now take key_id: &Uuid
  and bind the KEK envelope via kek_wrap_aad(*key_id). Four call sites
  (load_keys, generate_key, rotate_key encrypted_dek, import_key_material)
  updated. crypto_encrypt / crypto_decrypt take tenant_id: &str and bind
  user_data_aad. SM2 path unchanged (public-key encryption, no AEAD-AAD).
- key_service.rs::export_key envelope uses export_wrap_aad(*key_id,
  tenant_id) instead of Aad::empty(). The ExportWrap purpose tag (0x0003)
  differs from UserData (0x0001), preventing an attacker from lifting a
  user-data ciphertext and presenting it as an export envelope.

Tests (8 new): AES / SM4 round-trip with bound AAD, AES cross-key replay
rejected (primary defence), SM4 cross-key replay rejected, cross-version
forgery rejected, format_version ∈ {0, 1} back-compat (manual ring seal
with empty AAD), unknown format_version rejected, cross-tenant AAD
divergence verified at the AAD layer (defence in depth on top of the
PR-1.2 / PR-1.3 tenant pre-checks).

Verification (stable 1.88):
- cargo fmt --all -- --check: clean
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test --workspace: 1015 passed (plus 18 ignored), 0 failed.

README + CHANGELOG updated to reflect +9 aad.rs unit tests and +8 PR-1.4
keystore regression tests (total 998 → 1015 passed).
@EricZHANG1688

Copy link
Copy Markdown
Contributor Author

PR-1.4 / P0-5 — AAD 绑定(新增提交 aec661c)

新提交在原有 PR-1.1/1.2/1.3 基础上叠加了 PR-1.4 / P0-5(AAD 绑定)。

改动概要

  • 新增 crates/kms-core/src/aad.rs:Purpose 枚举(UserData / KekWrap / ExportWrap),42 字节 v2 AAD 线格式 = magic(2) + aad_version(2) + purpose(2) + key_id(16) + SHA-256(tenant)[..16] + version(4);3 个公开构造器 + 9 个单元测试。

  • 五个调用点接入绑定 AAD:

    • software/mod.rs encrypt(AES + SM4):user_data_aad(*key_id, &entry.meta.tenant_id, entry.meta.version),输出 format_version = 2;decrypt 按 format_version 分流。
    • postgres.rs encrypt_material / decrypt_material:加 key_id: &Uuid 参数,KEK 信封用 kek_wrap_aad(*key_id)。crypto_encrypt / crypto_decrypt 加 tenant_id: &str。
    • key_service.rs export_key 信封:export_wrap_aad(*key_id, tenant_id) 替代 Aad::empty()。

向后兼容

format_version ∈ {0, 1} 走 empty-AAD 分支(旧密文继续可解);format_version == 2 走绑定 AAD;未知 format_version 返回 Error::InvalidCiphertext。

测试(新增 8 个)

  • AES / SM4 round-trip with bound AAD
  • AES / SM4 跨 key 复制拒绝(核心防御)
  • 跨 version 伪造拒绝
  • format_version ∈ {0, 1} 向后兼容
  • 未知 format_version 拒绝
  • 跨租户 AAD 发散验证

验证(stable 1.88)

  • cargo fmt --all -- --check: clean
  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo test --workspace: 1015 passed (plus 18 ignored), 0 failed。

测试计数更新

  • kms-core: 282 → 291(+9 aad.rs 单元测试)
  • kms-keystore: 86 → 94(+8 PR-1.4 回归测试)
  • 合计:998 → 1015 passed

纵深防御

与 PR-1.2(service-layer tenant pre-check)+ PR-1.3(keystore-layer tenant verify)形成三层防御。

gm-kms agent added 8 commits September 23, 2026 09:04
… (PR-4.1 / P1-3)

Pre-PR-4.1 the KMS gRPC + REST listeners emitted only a tracing::warn!
when started without TLS. API keys + signed payloads transited in
the clear, which is unacceptable for production deployments.

PR-4.1 mirrors the KEK fail-fast pattern at
kms-keystore/src/postgres.rs:92-95: bail!() the startup unless the
operator explicitly opts in via KMS_ALLOW_INSECURE=1 (production
white-list) or KMS_DEV_MODE=1 (test / embedded-integration opt-in).

New kms-core::production_safety module exposes three helpers
(is_allow_insecure / is_dev_mode / is_insecure_opted_in). Both
env vars are read with strict '1' semantics — 'true' / 'yes' / 'on'
do NOT trigger the white-list.

Two pub(crate) helpers (grpc_tls_failfast_message / rest_tls_failfast_message)
extract the fail-fast decision into unit-testable functions instead
of leaving it inline in cmd::server::run, which has too much
network / keystore setup for end-to-end integration testing.

12 new unit tests cover all branches:
  - kms-core/production_safety.rs (5 tests): env-var semantics
  - src/cmd/server.rs::pr41_failfast_tests (7 tests): gRPC +
    REST fail-fast + opt-in + message-distinctness invariant

docs/requirements/N2-tls-failfast.md adds a bilingual requirement
spec (Chinese + English) and is registered in the requirements README.
PR-4.1 made the KMS gRPC/REST listeners refuse to start without
TLS unless KMS_ALLOW_INSECURE=1 (or KMS_DEV_MODE=1) is set. The
ZAP baseline scan runs against a plaintext localhost listener
(intentional, baseline scans run over HTTP for reproducibility),
so the test container must opt in explicitly via KMS_ALLOW_INSECURE.

Without this env var, the kms-target container exits during
startup with 'KMS gRPC requires TLS in production', the
localhost:8080/healthz probe never succeeds, and the spider
fails with DNS-resolution errors.
… / P1-4)

Pre-PR-4.4 audit of BackendTlsConfig::from_env found three
production-safety gaps:

  1. KMS_DB_TLS_MODE=no_verify was accepted unconditionally in
     production. Without server-cert validation, TLS encrypts
     traffic but not the channel — a MITM attacker can present
     any certificate.
  2. KMS_DB_TLS_MODE=disabled in non-dev mode was silently
     demoted to VerifyCa. Operators who thought they had
     Disabled were actually running with VerifyCa; if KMS_DB_TLS_CA_CERT
     was also missing, the connection would fail at first
     handshake instead of at startup.
  3. VerifyCa without KMS_DB_TLS_CA_CERT was accepted by
     from_env() (returning a VerifyCa config with no cert path)
     and only failed at first database connection.

PR-4.4 closes the gaps with three fail-fast gates, mirroring
the KEK pattern at kms-keystore/src/postgres.rs:92-95 and the
PR-4.1 KMS_API listener pattern:

  - no_verify in production -> anyhow::bail! with explicit
    'set KMS_ALLOW_INSECURE=1' guidance.
  - disabled in production -> anyhow::bail! with the same
    guidance.
  - verify_ca without non-empty KMS_DB_TLS_CA_CERT -> anyhow::bail!.
  - Each is reachable only via KMS_DEV_MODE=1 (test /
    embedded-integration) or KMS_ALLOW_INSECURE=1 (explicit
    production opt-in); both flags read through the shared
    kms_core::production_safety::is_insecure_opted_in() helper
    from PR-4.1.

The four callers (cmd/server.rs rate-limiter, quota-tracker,
mfa-pool paths, kms-keystore/src/repository.rs Postgres pool)
unwrap the new Result<_, anyhow::Error> with std::process::exit(1)
to enforce the fail-fast contract at startup.

14 new unit tests under pr44_db_tls_defaults_tests cover all
branches (default unset, verify_ca with/without cert, disabled
in dev/prod, no_verify in prod/with-opt-in, case-insensitive,
empty-string handling). Existing 296 kms-core + 94
kms-keystore unit tests all pass.

docs/requirements/N3-db-redis-tls-defaults.md registers the
requirement spec.
…_args (PR-4.4 follow-up)

CI's stable Rust 1.88 toolchain enforces clippy::uninlined_format_args
as an error; local nightly clippy doesn't. Three sites failed CI:

  - crates/kms-core/src/tls_config.rs:397 ('got: {}' -> 'got: {err}')
  - crates/kms-core/src/tls_config.rs:586 ('panic!("...", val, e)' -> 'panic!("...", {val:?}, {e})')
  - crates/kms-keystore/src/repository.rs:98 ('fail-fast: {}' -> 'fail-fast: {e}')

Inlined the format args to satisfy stable. No behavior change;
purely cosmetic adjustments to the format macro calls.
…low-up)

PR-4.4 made BackendTlsConfig::from_env fail-fast on production
unsafe DB / Redis TLS configs (verify_ca without CA cert,
no_verify / disabled in production). The ZAP baseline scan
container runs against kms-docker.toml which has redis=false,
rate_limit=false, quota=false, no DATABASE_URL — DB / Redis
TLS is not actually used, but cmd/server.rs eagerly calls
BackendTlsConfig::from_env() from maybe_create_mfa_pool /
maybe_create_redis_rate_limiter / maybe_create_quota_tracker,
so the check still fires.

Set KMS_DB_TLS_MODE=disabled + KMS_ALLOW_INSECURE=1 on the
ZAP target container so the in-memory fixture opts into
plaintext DB listeners (mirroring the listener-side opt-in
from PR-4.1).
…ilent empty material (PR-4.5 / P1-5)

Pre-PR-4.5 audit found that SoftwareKeystore::generate_key for
KeySpec::Sm9Signing | Sm9Encryption returned Ok(KeyMeta) with
material: Vec::new(). Every subsequent sign / encrypt / decrypt
call would fail at runtime (empty material is unusable for any
crypto op), but the API surface reported key creation success.

PR-4.5 replaces the Vec::new() with Err(Error::NotImplemented)
and an explicit text mentioning the KMS-SM9 master-key
management infrastructure that would be required for actual
SM9 user-key derivation (currently not integrated; the
gm-kms repo has kms-core/src/sm9_master_key.rs but no end-to-end
HSM / KMS-SM9 master-key management wired up).

Mirrors the existing Rsa4096 branch in generate_key (line 700-704
of software/mod.rs). The rotate path at line 1253-1262 already
returns Error::KeyOperationNotAllowed with a Sm9RotationAdapter
hint and remains unchanged.

4 new unit tests under pr45_sm9_generate_tests lock in:
  - SM9 signing returns NotImplemented (error mentions SM9).
  - SM9 encryption returns NotImplemented (error mentions SM9).
  - RSA-4096 still returns NotImplemented (regression guard).
  - SM2 still works and material is non-empty (regression guard).

The pre-existing kms-api/src/rotation.rs::test_sm9_direct_keystore_rotation_errors
relied on the old buggy behavior (it called generate_key(Sm9Signing)
which used to return Ok, then asserted rotate_key errored). Post-PR-4.5
that path is unreachable from the public API since SM9 generation
fails first. The test is replaced with a documentation comment
explaining the invariant; the rotation KeyOperationNotAllowed path
in software/mod.rs:1253-1262 stays in place for legacy/migration
cases but cannot be triggered via SoftwareKeystore's public API.

docs/requirements/N4-sm9-generate-not-implemented.md registers
the requirement spec.
…阶段 1/3)

Pre-PR-4.7: the master KEK was only loaded from the KMS_KEK env
var (plaintext in process environment, visible in /proc/<pid>/environ).
Two call sites (kms-keystore::postgres and kms-api::mfa) each
implemented their own inline hex-parsing logic, with copy-paste
duplication.

PR-4.7 introduces kms_core::kek_source::KekSource, a small enum
that supports three sources:
  - Env: KMS_KEK hex string (existing behavior, preserved)
  - File(PathBuf): KMS_KEK_FILE path to a 0600-mode file containing
    64 hex characters; mode bit is enforced on Unix and any
    non-0600 mode is rejected with InsecureFileMode
  - Missing: neither set (caller decides DEV-mode random or
    fail-hard, preserving existing behavior)

When both KMS_KEK and KMS_KEK_FILE are set, File wins — the file
channel is more secure because OS file permissions are enforced.
A whitespace-only path falls through to KMS_KEK (catches the
common 'KMS_KEK_FILE=""' / unset-but-present bug).

PR-4.7 is phase 1/3 of master plan §6/§7 P1-7:
  - Phase 2 (PR-4.8): HSM/TPM provider via kms-hsm
  - Phase 3 (PR-4.9): KEK rotation (kek_label persistence +
    multi-active-KEK)

Changes:
- crates/kms-core/src/kek_source.rs (new): KekSource enum,
  KekSourceError enum, from_env() / load() factory and resolver,
  17 unit tests in pr47_kek_source_tests covering env/file/missing
  resolution, hex parsing (valid/invalid/wrong-length/whitespace),
  file-mode enforcement (0600/0644/0666), file-not-found, empty
  file, and error-message content
- crates/kms-core/src/lib.rs: add 'pub mod kek_source'
- crates/kms-keystore/src/postgres.rs: replace inline hex parsing
  with KekSource::from_env().load(); DEV-mode random + fail-hard
  behavior preserved
- crates/kms-api/src/mfa.rs: replace inline hex parsing with
  KekSource::from_env().load(); plaintext-with-warning fallback
  preserved
- Cargo.toml: workspace version 0.2.1 -> 0.2.2 (patch bump; new
  pub API surface is additive)
- CHANGELOG.md: document the addition
- docs/requirements/N5-kek-source-layering.md (new): bilingual
  requirement spec
- docs/requirements/README.md: index the new requirement
…P2-6)

Pre-PR-4.11: the HMAC signing key file for WORM-backed signed
audit logs was a sibling of the WORM log file (path computed via
`worm_path.with_extension("signing_key")`). A compromised
process could replace BOTH the log AND the key, defeating WORM's
append-only integrity guarantee. PR-4.11 introduces an opt-in
way for operators to deploy the signing key at a path SEPARATE
from the WORM log directory (different filesystem / mount /
account recommended).

Changes:
- crates/kms-audit/src/worm_logger.rs:
  - `WormSignedAuditConfig` gains `signing_key_path:
    Option<PathBuf>` field (`None` = pre-PR-4.11 sibling
    behavior, byte-identical backward compat).
  - `WormSignedAuditConfig::with_signing_key_path(PathBuf)`
    builder.
  - `WormSignedAuditConfig::load_or_create_with_key_path(
    worm_path, key_path, seq)` factory (writes / loads the key
    at the operator-supplied path).
  - `WormSignedAuditConfig::effective_signing_key_path()` —
    SINGLE source of truth for the resolved key path.
  - Renamed internal `signing_key_path` helper to
    `default_signing_key_path` to reflect its now-default role
    (kept `pub` for tests; new public methods cover real usage).
  - New `pr411_signing_key_isolation_tests` module: 6 tests
    covering default-behavior preservation, custom-path override,
    0600 enforcement on Unix, and existing-key loading at the
    custom path.
- Cargo.toml: workspace version 0.2.2 -> 0.2.3 (patch; additive
  opt-in feature, no breaking changes).
- CHANGELOG.md: document the addition.
- docs/requirements/N6-worm-hmac-key-isolation.md (new):
  bilingual requirements document.
- docs/requirements/README.md: add N6 row to the index.

KEK integration (wrapping the HMAC key with the master KEK) is
out of scope for PR-4.11; deferred to PR-4.12 to keep this PR
within kms-audit crate only.
… / P2-9)

Pre-PR-4.14: the software keystore's `generate_sm2_key()` did:

```
let mut key = vec![0u8; 32];
rand::rng().fill_bytes(&mut key);
key
```

This produces 32 random bytes without enforcing the
GB/T 32918.1-2016 §5.1.4 invariant that an SM2 private
scalar `d` must be in `[1, n-1]` where `n` is the SM2
curve order. The probability of a bare 32-byte sample
falling outside this range is negligible (~ 2^-128), but the
absence of an explicit range check means:
  - any future upstream regression in scalar generation
    would be silently accepted; and
  - the boundary case `d == 0` would yield an invalid
    public key (point at infinity) that passes through to
    the SQL backend undetected.

The master plan identified this as P2-9
(gm与gm-kms源码级改进建议 §四 P2-9): 建议统一走
gm-kms agent added 2 commits September 23, 2026 23:22
Pre-PR-4.15: `PostgresKeystore.keys` was a single
`Arc<RwLock<HashMap<Uuid, KeyEntry>>>` with no capacity cap,
no eviction policy, and no metrics. Every key ever generated
stayed resident until process exit, exposing two production
risks:
  1. Unbounded memory growth (10K keys ~ hundreds of MB).
  2. Cold keys compete with hot keys for cache space.

The master plan identified this as P2-10
(gm与gm-kms源码级改进建议 §四 P2-10): 建议加惰性加载与
PR-4.15 introduced `with_in_memory_cap(n)` to bound
memory usage, but left a latent bug: keys beyond position
`n` were silently unreachable because `verify_tenant`
returned `KeyNotFound` on cache miss without trying the
DB. Production deployments with 1000 keys and cap=100
effectively rendered the last 900 keys unusable.

PR-4.17 closes the gap (per PR-4.15's SPEC which listed
this as follow-up):

  - New inherent method `PostgresKeystore::load_entry_from_db`
    that fetches metadata + encrypted material from
    PostgreSQL and decrypts with the KEK. Returns
    `Error::KeyNotFound` for absent keys, `Error::Internal`
    for KEK-rotation failures (loud error so operators
    notice) or pre-PR-2.x keys that lack encrypted
    material.
  - `verify_tenant` cache-miss path now calls
    `load_entry_from_db` instead of returning
    `KeyNotFound` outright. On success, the entry is
    inserted via `BoundedKeyCache::insert_with_eviction`
    (FIFO cap honoured automatically) and a
    `tracing::debug!` records the lazy load so operators
    can correlate cache misses in production logs.
  - Tenant isolation (PR-1.2 conflation) preserved: lazy
    load + wrong tenant still returns `KeyNotFound`.
  - 4 new tests: 3 live-DB `#[ignore]` integration tests
    covering cap-beyond, unknown-id, wrong-tenant; 1 unit
    smoke test verifying the helper is reachable via the
    inherent impl block (no public API change).
  - Note: PR-4.17 SPEC considered adding a Prometheus
    counter (`kms_keystore_lazy_loads_total`), but
    decided against it to keep `kms-keystore` from
    depending on the `metrics` crate (currently a
    `kms-api` dependency). Operators rely on
    `tracing::debug` log scraping instead.

Changes:
- crates/kms-keystore/src/postgres.rs:
    + `load_entry_from_db` (40 lines, KEK-aware)
    + `verify_tenant` cache-miss path rewritten
    + 4 tests added (3 #[ignore] live + 1 unit)
- Cargo.toml: 0.2.5 -> 0.2.6 (patch; no API change).
- Cargo.lock: workspace version bump.
- CHANGELOG.md: document the change.
- docs/requirements/N9-keystore-lazy-load.md (new):
  bilingual SPEC.
gm-kms agent added 2 commits September 24, 2026 01:56
PR-4.17 made cap-beyond keys lazy-loadable, but the
eager `load_keys()` path still blocked server startup
by ~5–10s for 10k keys (O(N) DB queries + KEK
decrypt × N). PR-4.19 fixes the blocking half:

  - New `PostgresKeystore::spawn_load_keys()` returns
    a `tokio::task::JoinHandle<Result<usize>>` so
    callers can continue with TCP bind / gRPC start
    without waiting for the DB. The returned
    `Result<usize>` carries the count of keys
    successfully inserted into the bounded cache.
  - Server startup paths in `src/cmd/server.rs`
    (`create_software_keystore`,
    `create_software_keystore_inner`) switched from
    `await load_keys()` to the new background
    preload. Pre-PR-4.19 the listen port bound after
    the DB round-trip; post-PR-4.19 it binds
    immediately.
  - Internal refactor: extracted
    `decrypt_material_static(kek, key_id, encrypted)`
    from `decrypt_material` so the spawned task can
    decrypt without a `&self` borrow. Added shared
    `load_one_into_cache` helper used by both
    sync (`load_keys`) and async (`spawn_load_keys`)
    paths.
  - `PostgresKeyRepository` now derives `Clone`
    (sqlx::PgPool is Arc-backed; zero-cost clone).
  - 4 new tests: 3 unit tests (helper type shape,
    too-short error path parity, spawn signature) +
    1 `#[ignore]` live-DB integration test
    (pr419_load_keys_and_spawn_have_same_outcome).
  - PR-4.17's lazy load is unchanged; it still
    covers cache misses during the pre-preload
    window (rare in practice, important for tests).

Changes:
- crates/kms-keystore/src/postgres.rs:
    + `spawn_load_keys` (~70 lines)
    + `decrypt_material_static` (~50 lines)
    + `load_one_into_cache` shared inner helper
    + 4 tests
- crates/kms-keystore/src/repository.rs:
    `PostgresKeyRepository` now `#[derive(Clone)]`.
- src/cmd/server.rs: 2 caller sites switched to
  background preload.
- Cargo.toml: 0.2.6 -> 0.2.7 (patch; new API).
- Cargo.lock: workspace version bump.
- CHANGELOG.md: document the change.
- docs/requirements/N10-keystore-background-load.md
  (new): bilingual SPEC.
- '[0.2.1] — Unreleased' -> '[0.2.1] — 2026-09-05' (v0.2.1 tag
  was actually cut on 2026-09-05 at commit 84098d3, not pending)
- duplicate '[0.1.0] — 2026-06-29' section renamed to
  '[Pre-0.1.0 development] — 2026-06-29' with explicit audit-trail
  disclaimer: pre-release development snapshots folded into 0.1.0,
  predating the v0.1.0 tag (commit 18b32a3, 2026-08-27); NOT part
  of any published release
- '[0.1.0] — 2026-08-27' marked as 'Initial public release' for
  clarity

No source code changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant