Fixes#198 (reconciles #162). PayloadCipher::encrypt_text_at_rest swallowed any AEAD encryption failure with .unwrap_or_default(), silently persisting an empty string in place of ciphertext — a value indistinguishable from a genuinely empty body on read-back. This is a silent data-loss bug on the mail body write path.
Fix:
encrypt_text_at_rest now returns Result<String, CipherError>. The empty-input short-circuit (Ok(String::new())) is preserved — an empty body legitimately has nothing to encrypt, and is not a failure.
Added DatabaseError::Encryption(String) (mirrors the KeyProvisioning style/doc-comment) plus a From<CipherError> for DatabaseError impl.
save_email now resolves enc_plain/enc_html via .transpose()?beforeself.pool.begin() runs, so a cipher failure aborts the call before any SQL executes — no row is ever written with a corrupted/empty body column.
Verified (via cargo check-all across the workspace) that encrypt_text_at_rest has no other callers outside nuncio-store; the only other hit is a doc-comment reference in vault.rs.
Type-signature proof of fail-closed behavior
The PRIMARY evidence this bug can't recur is structural, not test-based: after this change there is nounwrap_or_default() / .ok() / unwrap_or(...) anywhere between PayloadCipher::encrypt_bytes and the SQL bind in save_email. Every fallible step (encrypt_bytes → encrypt_text_at_rest → the .transpose()? in save_email) is connected by ?, so a CipherError can only ever propagate as Err(DatabaseError::Encryption(_)) — it is not possible for it to compile down to a silently-substituted empty string.
Testing approach (honest, no fabricated failure)
AES-256-GCM is realistically infallible for in-memory plaintexts (failure requires exceeding ~64 GiB of data for a single nonce, which is not a scenario this codebase can hit for email bodies). Per the story brief, I deliberately did not manufacture a contrived forced-failure unit test for that unreachable path. Instead:
The existing success-path tests (text_at_rest_encryption_decryption_roundtrip, text_at_rest_wrong_key_fails_to_decrypt) were updated to the new Result signature using .expect(...) (tests are exempt from the unwrap/expect lint) and still pass.
I looked for a clean injectable failure seam into save_email (e.g. swapping the cipher via a trait) but PayloadCipher is an associated-function-only type with no room to inject a fake failure without weakening its public API for production callers, which the story brief says not to do. So this PR relies on the type-signature proof above plus the passing success-path tests, rather than a fake db.rs-level failure test.
Out of scope / follow-up
decrypt_text_at_rest still silently returns an empty string on any decode/decrypt/UTF-8 failure — that's a read-path issue (turns a corrupted/tampered ciphertext into a fabricated "empty body" instead of a decode error), distinct from this write-path data-loss bug. Left as-is per the story brief; recommend the PO file a follow-up issue to make the read path fail closed too (return Result and surface a decode error to callers instead of coercing to "").
test result: ok. 25 passed; 0 failed; 0 ignored ... (nuncio-cal)
test result: ok. 2 passed; 0 failed; 0 ignored ... (nuncio-cal doctest-adjacent)
test result: ok. 28 passed; 0 failed; 0 ignored ... (nuncio-cli)
test result: ok. 4 passed; 0 failed; 0 ignored ... (nuncio-contacts)
test result: ok. 12 passed; 0 failed; 0 ignored ...
test result: ok. 3 passed; 0 failed; 0 ignored ...
test result: ok. 52 passed; 0 failed; 0 ignored ...
test result: ok. 26 passed; 0 failed; 0 ignored ...
test result: ok. 2 passed; 0 failed; 0 ignored ...
test result: ok. 52 passed; 0 failed; 0 ignored ...
test result: ok. 3 passed; 0 failed; 0 ignored ...
test result: ok. 23 passed; 0 failed; 0 ignored ...
test result: ok. 57 passed; 0 failed; 0 ignored ... (nuncio-store, includes cipher.rs)
test result: ok. 117 passed; 0 failed; 0 ignored ... (nunciod unit tests)
test result: ok. 1 passed; 0 failed; 0 ignored ... (accounts_lifecycle_e2e_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (calendar_e2e_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (contacts_e2e_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (daemon_e2e_test)
test result: ok. 2 passed; 0 failed; 0 ignored ... (filters_triage_e2e_test)
test result: ok. 6 passed; 0 failed; 0 ignored ... (grpc_auth_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (spine_e2e_test)
Total: 419 passed, 0 failed, 0 ignored across the workspace. The local pre-commit hook (identical fmt/clippy/test gate) also passed on this commit.
Test plan
cargo fmt --all -- --check
cargo check-all
cargo test-all (0 ignored)
Verified no other callers of encrypt_text_at_rest outside nuncio-store
Verified no unwrap_or_default/.ok()/unwrap_or remains between encrypt_bytes and the SQL bind
## Summary
Fixes #198 (reconciles #162). `PayloadCipher::encrypt_text_at_rest` swallowed any AEAD encryption failure with `.unwrap_or_default()`, silently persisting an **empty string** in place of ciphertext — a value indistinguishable from a genuinely empty body on read-back. This is a silent data-loss bug on the mail body write path.
**Fix:**
- `encrypt_text_at_rest` now returns `Result<String, CipherError>`. The empty-input short-circuit (`Ok(String::new())`) is preserved — an empty body legitimately has nothing to encrypt, and is not a failure.
- Added `DatabaseError::Encryption(String)` (mirrors the `KeyProvisioning` style/doc-comment) plus a `From<CipherError> for DatabaseError` impl.
- `save_email` now resolves `enc_plain`/`enc_html` via `.transpose()?` **before** `self.pool.begin()` runs, so a cipher failure aborts the call before any SQL executes — no row is ever written with a corrupted/empty body column.
- Verified (via `cargo check-all` across the workspace) that `encrypt_text_at_rest` has no other callers outside `nuncio-store`; the only other hit is a doc-comment reference in `vault.rs`.
## Type-signature proof of fail-closed behavior
The PRIMARY evidence this bug can't recur is structural, not test-based: after this change there is **no** `unwrap_or_default()` / `.ok()` / `unwrap_or(...)` anywhere between `PayloadCipher::encrypt_bytes` and the SQL bind in `save_email`. Every fallible step (`encrypt_bytes` → `encrypt_text_at_rest` → the `.transpose()?` in `save_email`) is connected by `?`, so a `CipherError` can only ever propagate as `Err(DatabaseError::Encryption(_))` — it is not possible for it to compile down to a silently-substituted empty string.
## Testing approach (honest, no fabricated failure)
AES-256-GCM is realistically infallible for in-memory plaintexts (failure requires exceeding ~64 GiB of data for a single nonce, which is not a scenario this codebase can hit for email bodies). Per the story brief, I deliberately did **not** manufacture a contrived forced-failure unit test for that unreachable path. Instead:
- The existing success-path tests (`text_at_rest_encryption_decryption_roundtrip`, `text_at_rest_wrong_key_fails_to_decrypt`) were updated to the new `Result` signature using `.expect(...)` (tests are exempt from the `unwrap`/`expect` lint) and still pass.
- I looked for a clean injectable failure seam into `save_email` (e.g. swapping the cipher via a trait) but `PayloadCipher` is an associated-function-only type with no room to inject a fake failure without weakening its public API for production callers, which the story brief says not to do. So this PR relies on the type-signature proof above plus the passing success-path tests, rather than a fake `db.rs`-level failure test.
## Out of scope / follow-up
`decrypt_text_at_rest` still silently returns an empty string on any decode/decrypt/UTF-8 failure — that's a **read-path** issue (turns a corrupted/tampered ciphertext into a fabricated "empty body" instead of a decode error), distinct from this write-path data-loss bug. Left as-is per the story brief; recommend the PO file a follow-up issue to make the read path fail closed too (return `Result` and surface a decode error to callers instead of coercing to `""`).
## Gate output
`cargo fmt --all -- --check` — clean, no diff.
`cargo check-all` (clippy `--all-targets --workspace -D warnings`) — clean, zero warnings.
`cargo test-all` — all green, 0 ignored:
```
test result: ok. 25 passed; 0 failed; 0 ignored ... (nuncio-cal)
test result: ok. 2 passed; 0 failed; 0 ignored ... (nuncio-cal doctest-adjacent)
test result: ok. 28 passed; 0 failed; 0 ignored ... (nuncio-cli)
test result: ok. 4 passed; 0 failed; 0 ignored ... (nuncio-contacts)
test result: ok. 12 passed; 0 failed; 0 ignored ...
test result: ok. 3 passed; 0 failed; 0 ignored ...
test result: ok. 52 passed; 0 failed; 0 ignored ...
test result: ok. 26 passed; 0 failed; 0 ignored ...
test result: ok. 2 passed; 0 failed; 0 ignored ...
test result: ok. 52 passed; 0 failed; 0 ignored ...
test result: ok. 3 passed; 0 failed; 0 ignored ...
test result: ok. 23 passed; 0 failed; 0 ignored ...
test result: ok. 57 passed; 0 failed; 0 ignored ... (nuncio-store, includes cipher.rs)
test result: ok. 117 passed; 0 failed; 0 ignored ... (nunciod unit tests)
test result: ok. 1 passed; 0 failed; 0 ignored ... (accounts_lifecycle_e2e_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (calendar_e2e_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (contacts_e2e_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (daemon_e2e_test)
test result: ok. 2 passed; 0 failed; 0 ignored ... (filters_triage_e2e_test)
test result: ok. 6 passed; 0 failed; 0 ignored ... (grpc_auth_test)
test result: ok. 1 passed; 0 failed; 0 ignored ... (spine_e2e_test)
```
Total: 419 passed, 0 failed, 0 ignored across the workspace. The local pre-commit hook (identical fmt/clippy/test gate) also passed on this commit.
## Test plan
- [x] `cargo fmt --all -- --check`
- [x] `cargo check-all`
- [x] `cargo test-all` (0 ignored)
- [x] Verified no other callers of `encrypt_text_at_rest` outside `nuncio-store`
- [x] Verified no `unwrap_or_default`/`.ok()`/`unwrap_or` remains between `encrypt_bytes` and the SQL bind
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Fixes #198 (reconciles #162).
PayloadCipher::encrypt_text_at_restswallowed any AEAD encryption failure with.unwrap_or_default(), silently persisting an empty string in place of ciphertext — a value indistinguishable from a genuinely empty body on read-back. This is a silent data-loss bug on the mail body write path.Fix:
encrypt_text_at_restnow returnsResult<String, CipherError>. The empty-input short-circuit (Ok(String::new())) is preserved — an empty body legitimately has nothing to encrypt, and is not a failure.DatabaseError::Encryption(String)(mirrors theKeyProvisioningstyle/doc-comment) plus aFrom<CipherError> for DatabaseErrorimpl.save_emailnow resolvesenc_plain/enc_htmlvia.transpose()?beforeself.pool.begin()runs, so a cipher failure aborts the call before any SQL executes — no row is ever written with a corrupted/empty body column.cargo check-allacross the workspace) thatencrypt_text_at_resthas no other callers outsidenuncio-store; the only other hit is a doc-comment reference invault.rs.Type-signature proof of fail-closed behavior
The PRIMARY evidence this bug can't recur is structural, not test-based: after this change there is no
unwrap_or_default()/.ok()/unwrap_or(...)anywhere betweenPayloadCipher::encrypt_bytesand the SQL bind insave_email. Every fallible step (encrypt_bytes→encrypt_text_at_rest→ the.transpose()?insave_email) is connected by?, so aCipherErrorcan only ever propagate asErr(DatabaseError::Encryption(_))— it is not possible for it to compile down to a silently-substituted empty string.Testing approach (honest, no fabricated failure)
AES-256-GCM is realistically infallible for in-memory plaintexts (failure requires exceeding ~64 GiB of data for a single nonce, which is not a scenario this codebase can hit for email bodies). Per the story brief, I deliberately did not manufacture a contrived forced-failure unit test for that unreachable path. Instead:
text_at_rest_encryption_decryption_roundtrip,text_at_rest_wrong_key_fails_to_decrypt) were updated to the newResultsignature using.expect(...)(tests are exempt from theunwrap/expectlint) and still pass.save_email(e.g. swapping the cipher via a trait) butPayloadCipheris an associated-function-only type with no room to inject a fake failure without weakening its public API for production callers, which the story brief says not to do. So this PR relies on the type-signature proof above plus the passing success-path tests, rather than a fakedb.rs-level failure test.Out of scope / follow-up
decrypt_text_at_reststill silently returns an empty string on any decode/decrypt/UTF-8 failure — that's a read-path issue (turns a corrupted/tampered ciphertext into a fabricated "empty body" instead of a decode error), distinct from this write-path data-loss bug. Left as-is per the story brief; recommend the PO file a follow-up issue to make the read path fail closed too (returnResultand surface a decode error to callers instead of coercing to"").Gate output
cargo fmt --all -- --check— clean, no diff.cargo check-all(clippy--all-targets --workspace -D warnings) — clean, zero warnings.cargo test-all— all green, 0 ignored:Total: 419 passed, 0 failed, 0 ignored across the workspace. The local pre-commit hook (identical fmt/clippy/test gate) also passed on this commit.
Test plan
cargo fmt --all -- --checkcargo check-allcargo test-all(0 ignored)encrypt_text_at_restoutsidenuncio-storeunwrap_or_default/.ok()/unwrap_orremains betweenencrypt_bytesand the SQL bind