Skip to content

feat(kms): AWS-shaped binary ciphertext blob with AES-256-GCM#766

Merged
vieiralucas merged 2 commits intomainfrom
worktree-batch9-kms-envelope
Apr 25, 2026
Merged

feat(kms): AWS-shaped binary ciphertext blob with AES-256-GCM#766
vieiralucas merged 2 commits intomainfrom
worktree-batch9-kms-envelope

Conversation

@vieiralucas
Copy link
Copy Markdown
Member

@vieiralucas vieiralucas commented Apr 25, 2026

Summary

Replaces the textual envelope (fakecloud-kms:<key>:<base64-plaintext>) — which round-tripped through real SDKs but leaked plaintext to anyone who base64-decoded the bytes — with an AWS-compatible opaque binary ciphertext blob shaped as:

version (4) | key-id len (8) | key-id (N) | IV (12) | ct-len (4) | ciphertext (M) | AES-GCM tag (16)

Encrypted with AES-256-GCM under a per-account 32-byte master key generated lazily and persisted in KmsState, so ciphertexts produced before a server restart still decrypt afterwards.

  • New module fakecloud-kms::blob (encode / decode) with unit coverage: round-trip, leakage check, tampering rejection, wrong-master-key rejection, IV non-determinism.
  • All Encrypt, GenerateDataKey(WithoutPlaintext)?, GenerateDataKeyPair(WithoutPlaintext)?, ReEncrypt, and the cross-service KmsServiceHook::encrypt paths emit the new blob.
  • Decrypt and the hook's decrypt accept three formats: new blob, legacy fakecloud-kms: text envelope, legacy fakecloud-imported: XOR envelope. Older ciphertexts persisted by prior versions still round-trip.
  • KmsState.master_key_bytes is #[serde(default = ...)] so old snapshots load with a fresh master key (their pre-existing ciphertexts use the legacy textual envelope, which still decrypts).

Part of batch 9/10 of the gap-fill plan.

Test plan

  • 133 KMS unit tests pass (cargo test -p fakecloud-kms --lib)
  • new E2E suite kms_blob_format (3 tests): version header, plaintext non-leakage, GenerateDataKey wrapping, legacy decrypt fallback
  • cross-service KMS suites (sqs_kms, ssm_kms, secretsmanager_kms, sns_ddb_kms, ecr_kms_encryption, s3_sse_kms) all pass
  • kms_persistence E2E pass (master key now survives restart)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all clean

Summary by cubic

Switch KMS ciphertext to an AWS-shaped opaque binary blob using AES-256-GCM, with the key-id bound as AEAD AAD to prevent plaintext leakage and header tampering. All encrypt paths now emit the blob; decrypt remains compatible with legacy formats.

  • New Features

    • Added fakecloud-kms::blob (encode/decode) for the AWS-shaped binary format; the key-id is bound via AEAD AAD so any header tampering fails decryption.
    • Uses a per-account 32-byte AES-256-GCM master key persisted in KmsState.
    • Encrypt, GenerateDataKey(WithoutPlaintext)?, GenerateDataKeyPair(WithoutPlaintext)?, ReEncrypt, and KmsServiceHook::encrypt now return the blob.
    • Decrypt (and hook) accept: new blob, legacy fakecloud-kms: text, and legacy fakecloud-imported: XOR. New E2E tests cover version header, no plaintext leakage, wrapping behavior, and legacy fallback.
  • Migration

    • No action required. Older ciphertexts still decrypt; new ciphertexts are opaque binary blobs (base64 on the wire).

Written for commit 015e561. Summary will update on new commits.

Replaces the leaky textual envelope (`fakecloud-kms:<key>:<base64-plaintext>`)
with an AWS-compatible opaque binary ciphertext blob shaped as:

  version (4) | key-id len (8) | key-id (N) | IV (12) | ct-len (4) |
  ciphertext (M) | AES-GCM tag (16)

Encrypted with AES-256-GCM under a per-account 32-byte master key
generated lazily and persisted in `KmsState` (so ciphertexts produced
before a server restart still decrypt afterwards). Anyone inspecting
the ciphertext bytes sees binary structure, not the plaintext.

- New module `fakecloud-kms::blob` with `encode` / `decode` and unit
  coverage (round-trip, leakage check, tampering rejection,
  wrong-master-key rejection, IV non-determinism).
- `KmsState` carries `master_key_bytes: Vec<u8>` (serde default
  generates a fresh random key on first construction; `reset()`
  preserves the key so existing ciphertexts still decrypt).
- All `Encrypt`, `GenerateDataKey(WithoutPlaintext)?`,
  `GenerateDataKeyPair(WithoutPlaintext)?`, `ReEncrypt`, and the
  cross-service `KmsServiceHook::encrypt` paths now emit the new
  blob.
- `Decrypt` and the hook's `decrypt` accept three formats: the new
  AWS-shaped blob, the legacy `fakecloud-kms:` text envelope, and
  the legacy `fakecloud-imported:` XOR envelope. Older ciphertexts
  persisted by prior versions still round-trip.

E2E: new `kms_blob_format` suite asserts version header, plaintext
non-leakage, GenerateDataKey wrapping, and legacy decrypt fallback.
The cross-service KMS suites (sqs_kms, ssm_kms, secretsmanager_kms,
sns_ddb_kms, ecr_kms_encryption, s3_sse_kms) and the KMS persistence
test all stay green.
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 25, 2026

Codecov Report

❌ Patch coverage is 78.50000% with 43 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/fakecloud-kms/src/hook.rs 0.00% 30 Missing ⚠️
crates/fakecloud-kms/src/service.rs 76.19% 10 Missing ⚠️
crates/fakecloud-kms/src/blob.rs 97.56% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/fakecloud-kms/src/service.rs">

<violation number="1" location="crates/fakecloud-kms/src/service.rs:54">
P1: The blob header key ID is not cryptographically bound to the ciphertext, so `Decrypt` can return a tampered `KeyId` while still producing valid plaintext.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// Modern AWS-shaped blob (AES-256-GCM under the per-account master
// key) — try this first. Older textual envelopes fall through.
if let Some(decoded) = crate::blob::decode(&state.master_key_bytes, &ciphertext_bytes) {
let key = state.keys.get(&decoded.key_id).ok_or_else(|| {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The blob header key ID is not cryptographically bound to the ciphertext, so Decrypt can return a tampered KeyId while still producing valid plaintext.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/fakecloud-kms/src/service.rs, line 54:

<comment>The blob header key ID is not cryptographically bound to the ciphertext, so `Decrypt` can return a tampered `KeyId` while still producing valid plaintext.</comment>

<file context>
@@ -47,6 +47,26 @@ fn decode_ciphertext_envelope(
+    // Modern AWS-shaped blob (AES-256-GCM under the per-account master
+    // key) — try this first. Older textual envelopes fall through.
+    if let Some(decoded) = crate::blob::decode(&state.master_key_bytes, &ciphertext_bytes) {
+        let key = state.keys.get(&decoded.key_id).ok_or_else(|| {
+            AwsServiceError::aws_error(
+                StatusCode::BAD_REQUEST,
</file context>
Fix with Cubic

Cubic flagged P1: the key-id in the blob header was a plain prefix.
An attacker could rewrite the embedded KeyId without invalidating the
AES-GCM ciphertext, so Decrypt would return tampered KeyId metadata
alongside genuine plaintext.

Pass the key-id as Associated Authenticated Data on encrypt and
verify it on decrypt. Tampering with the header bytes flips the AAD,
AES-GCM authentication fails, and decode returns None — the response
falls through to InvalidCiphertextException.
@vieiralucas vieiralucas merged commit 7d3f990 into main Apr 25, 2026
38 checks passed
@vieiralucas vieiralucas deleted the worktree-batch9-kms-envelope branch April 25, 2026 22:23
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