feat(security): Wrap secrets with secrecy crate (#369)#912
Conversation
a9abce7 to
43b1cb5
Compare
| #[cfg_attr(feature = "validate", validate(length(max = 255)))] | ||
| pub oidc_client_secret: Option<String>, | ||
| /// returned back. Wrapped in [`SecretString`] to prevent accidental | ||
| /// exposure via Debug/tracing; redacted (never exposed) when serialized into |
There was a problem hiding this comment.
I think you should not add comment "Wrapped in [SecretString] to prevent accidental
/// exposure via Debug/tracing; redacted (never exposed) ...." everywhere. The purpose is self explaining. I guess all new docstrings changes are not really necessary
There was a problem hiding this comment.
Agreed — dropped the "Wrapped in SecretString …" docstrings throughout. Redaction is now a property of the RedactedSecret type itself, so the fields just carry their functional description.
| !rendered.contains("CSLEAK"), | ||
| "leaked client secret: {rendered}" | ||
| ); | ||
| assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); |
There was a problem hiding this comment.
this opens doors to way too many fixes in unittests should the 'secrecy' crate change the content. Just a check for not containing plain text secret should be sufficient
There was a problem hiding this comment.
Done — these redaction tests now assert only that the plaintext secret is absent from the rendered output; I removed the contains("[REDACTED]") assertions here and in the sibling create/update tests so nothing depends on the exact marker text.
There was a problem hiding this comment.
this should be hidden as well
There was a problem hiding this comment.
Done — passcode is now a RedactedSecret in both the api-types DTO and the core-types UserTotpAuthRequest, so it no longer leaks via Debug/serialization. verify_totp reads it through expose_secret() at the point of use.
There was a problem hiding this comment.
Done — TokenAuth.id is now a RedactedSecret; it is exposed only at the authorize_by_token call boundary via expose_secret().
| // which runs on the wrapped value at the service layer for every write path. | ||
|
|
||
| /// Redact an `Option<SecretString>` on serialize. | ||
| pub(crate) fn serialize_secret_redacted<S>( |
There was a problem hiding this comment.
ehm, I am actually not sure you need this. Have a look at K8sAuthRequest. Maybe I am wrong though
There was a problem hiding this comment.
You were right. I removed secret_serde.rs entirely. Rather than a module of serialize-helpers, redaction is now built into the RedactedSecret newtype, so DTOs just #[derive(Serialize)] with no per-field glue. (K8sAuthRequest intentionally stays on SecretString + its local serializer — it serializes the JWT exposed to forward it, the opposite requirement, so it should not use the redacting type.)
|
|
||
| /// Serialize an optional secret as a fixed redaction marker so that it never | ||
| /// leaks in Debug/policy/audit payloads while still signalling presence. | ||
| fn serialize_secret_redacted<S>( |
There was a problem hiding this comment.
now again the nearly same function as above
There was a problem hiding this comment.
Removed — there is no longer a per-crate serializer to duplicate. RedactedSecret is defined once in core-types and api-types now depends on it directly, so both layers share the same type and its single Serialize impl.
| /// Manual `PartialEq` (the derive cannot be used because `oidc_client_secret` | ||
| /// is a [`SecretString`], which does not implement `PartialEq`). Preserves the | ||
| /// pre-wrapping equality contract by comparing the exposed secret values. | ||
| impl PartialEq for IdentityProvider { |
There was a problem hiding this comment.
This is bad. I fear when we add new field to any struct that manually overrides PartialEq we definitely forget to extend the check here. Please investigate for another solution - it is just a door for bugs
There was a problem hiding this comment.
Agreed, that hand-written impl was fragile for exactly that reason. RedactedSecret implements PartialEq (comparing the underlying values), so IdentityProvider derives PartialEq normally again — a newly added field is now automatically included in the comparison and the manual impl is gone. Same approach lets UserTotpAuthRequest keep its derived PartialEq/Default.
43b1cb5 to
7131791
Compare
|
Thanks for the review. I reworked the secret handling around a single
|
7131791 to
29b7957
Compare
| derive_builder = { workspace = true, optional = true } | ||
| eyre.workspace = true | ||
| http = { workspace = true, optional = true } | ||
| openstack-keystone-core-types = { workspace = true, optional = true } |
There was a problem hiding this comment.
This is for sure undesired, this pulls lots of very heavy dependencies to every crate that is only interested in API types
There was a problem hiding this comment.
Agreed, that was wrong. Reverted it: core-types is optional again and gated under builder/conv like before, so a plain api-types consumer does not pull it in. The DTOs use secrecy::SecretString directly now, which api-types already had as a dependency, so nothing heavy gets added.
| #[derive(Clone, Default)] | ||
| pub struct SecretField(SecretString); | ||
|
|
||
| impl fmt::Debug for SecretField { |
There was a problem hiding this comment.
I also do not really get why you do this. SecretString already prevents exposure of the content which is why the crate is used
There was a problem hiding this comment.
You are right, dropped the newtype. The fields are just SecretString now with a small serialize_with helper for transport, same as K8sAuthRequest. The only thing the wrapper bought me was a derivable PartialEq on IdentityProvider, but K8sAuthRequest does not derive PartialEq either, so I removed it from the secret-bearing structs and switched the two driver tests that compared the whole struct to compare fields instead. No manual PartialEq anywhere.
Wrap every remaining plaintext secret in `secrecy::SecretString` so it can never be exposed through `Debug`, `#[tracing::instrument]`, or accidental serialization, and redact secrets to `"[REDACTED]"` on the serialize paths that feed OPA policy input and audit payloads. Part of issue openstack-experimental#369 (token / password / application-credential secret), done as one PR since touching api-types pulls through every downstream crate. Wrapped, edge-to-consumer: - OIDC `oidc_client_secret` across the api DTO, the three core-types domain structs, the driver conversions, and the token-exchange consumer. `oidc_client_id` stays plaintext (per maintainer). - User/auth passwords: lifted the existing deep-layer wrapping up to the wire so `UserCreate`/`UserUpdate`/`UserPassword` and the whole auth request tree are `SecretString` from deserialize down; exposure now happens only at hash/verify/`validate_password`. - Config `invalid_password_hash_key` (mirrors `database.connection`). - OIDC token-exchange `id_token`/`access_token` bearer tokens, which were plaintext in a `Debug`-deriving struct. This also fixes three pre-existing plaintext leaks: `oidc_client_secret` and user `password` were serialized in the clear into OPA policy input via `json!({...})`; they now redact. Security hardening: - Restore the empty-password guard centrally in `SecurityComplianceProvider::validate_password` (rejects an empty password on every write path). The per-DTO `length(min = 1)` check could not be kept on the wrapped field: validator's `custom` requires the field to be `Serialize`, which `SecretString` deliberately is not. Notes: - The Sea-ORM `oidc_client_secret` column stays plaintext `String`; the secret is unwrapped only at the final DB-write boundary. - `PartialEq`/`Default` are dropped where unused and re-implemented manually where tests rely on them (`IdentityProvider`, `UserPasswordAuthRequest`). - OpenAPI schema is byte-for-byte unchanged (fields still `string`). - Adds an adversarial redaction/breakage test suite (flatten+redact, deep-nested serialize, OPA-path, Debug matrix, empty-password guard, response-never-leaks, OIDC token redaction). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Yousef Hussein <ymh1874@gmail.com>
29b7957 to
56ac8df
Compare
Address review feedback on the secret wrapping. Drop the SecretField newtype. `secrecy::SecretString` already keeps the value out of `Debug`, which is the reason the crate is used, so the DTO fields hold a `SecretString` directly and expose it for transport with a small `serialize_with` helper, the same way `K8sAuthRequest` does. - Keep api-types decoupled from core-types: the dependency is optional again and gated behind the builder/conv features as before, so a crate that only wants the API types does not pull it in. api-types uses `secrecy` directly, which it already depended on. - Drop `PartialEq` from the secret-bearing structs. `SecretString` does not implement it and `K8sAuthRequest` does not derive it either, so there is no hand-written impl to forget a field. The two driver tests that compared a whole `IdentityProvider` now compare fields. - Wrap the TOTP `passcode` and the token-auth `id`, and skip the domain identity provider's client secret on serialize since it is never returned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Yousef Hussein <ymh1874@gmail.com>
56ac8df to
2e15c47
Compare
TL;DR
Wraps every remaining plaintext secret in the codebase — the OIDC
client_secret, user/auth passwords (all the way up to the wire), the configinvalid_password_hash_key, and the OIDC token-exchange bearer tokens — insecrecy::SecretString, so they can never leak throughDebug,#[tracing::instrument], or accidental serialization. Secrets that must appear inOPA policy / audit payloads are redacted to
"[REDACTED]"instead of exposed.Along the way this fixes three pre-existing plaintext leaks into OPA policy
input and closes an empty-password acceptance gap. The public OpenAPI schema
is byte-for-byte unchanged, and the change is covered by an adversarial
redaction/breakage test suite. Part of #369; delivered as one PR because touching
api-typespulls through every downstream crate.Background
secrecywas already a workspace dependency with an established pattern(
database.connection: SecretString), and passwords were already wrapped at theprovider/backend layers. This PR closes the remaining gaps the audit found and
lifts the wrapping to a consistent edge-to-consumer boundary: a secret is
SecretStringfrom the moment it is deserialized off the wire (or read from theDB) until the single point where it is genuinely consumed (hash / verify / HTTP
form / DB write), where it is explicitly
.expose_secret()'d.What is wrapped (edge → consumer)
oidc_client_secretapi-typesDTO →core-types(3 structs) → SQL driver conversions → token-exchange consumer.oidc_client_idstays plaintext (per maintainer).UserCreate/UserUpdate/UserPassword+ the full nestedAuthRequesttree, and the mirrorcore-typesdomain structs, are nowSecretStringfrom deserialize down. Exposure happens only athash_password/verify_password/validate_password.invalid_password_hash_keySecurityComplianceProvider(mirrorsdatabase.connection).id_token/access_tokenTokenExchangeResponsebearer tokens, previously plaintextStringin aDebug-deriving struct; exposed only at the JWT-verify boundary.Redaction, not exposure
SecretStringintentionally does not implementSerialize. For the DTOs thatmust stay serializable (they are embedded in OPA policy input / audit payloads),
a
serialize_withhelper renders a present secret as a fixed"[REDACTED]"marker — field presence is preserved, the value never leaves. This is deliberately
different from the existing "expose once" helper used for the one-time API-key
token (that one is part of the API contract).
Pre-existing leaks this fixes
json!({"identity_provider": current}),json!({"user": req.user})(and thecreate/update variants) serialize the domain/DTO structs straight into OPA
policy input. Before this PR those payloads contained the plaintext
oidc_client_secretand userpassword. They now redact.Security hardening — empty-password guard
Wrapping the password meant the per-DTO
length(min = 1)validation could not bekept: validator 0.20's
customunconditionally callsValidationError::add_param(&field), which requires the field to beSerialize—and secrets deliberately are not. Without that guard, on a default deployment
(no
password_regex) an empty password would be accepted and stored (bcrypthashes an empty string fine).
Fixed by moving the non-empty check into the correct central layer —
SecurityComplianceProvider::validate_password— which runs on the wrapped valuefor every write path (create / update / change). This is strictly more secure
than before (previously only create was guarded).
Design decisions / notes
oidc_client_secretcolumn stays plaintextString. Wrapping theDB column would require custom
TryGetable/Into<Value>impls and would notstop SQL-parameter-level logging anyway; the DB write is the final consuming
method, where the secret is unwrapped.
PartialEq/Defaultare dropped from the wrapped structs where nothingrelies on them (matching the existing app-cred / k8s pattern), and
re-implemented manually where tests do rely on them (
IdentityProviderread model;
UserPasswordAuthRequest), preserving the prior public contract.#[schema(value_type = String / Option<String>)]keepsevery wrapped field documented exactly as before; the generated spec has zero
structural diff vs
main.SecretStringare fundamentally incompatible for field-levellength/custom validation (see the empty-password section) — documented inline so
a future pass doesn't reintroduce it.
Testing & verification
cargo check --workspace --all-targets --all-features✅cargo clippy --lib --tests --all-features✅ ·cargo fmt --check✅keystone, and the SQL drivers.
main— 0 structural changes.#[serde(flatten)] extra+ customserialize_with+skiponUserCreate/UserUpdate(the exact struct serialized to OPA) — password redacted,extrapreserved, via both
to_stringandto_value.AuthRequestserialize redaction (password 4 levels deep).Debugredaction matrix across every wrapped type, incl. the config hash key.Debugredaction; deserialize edge cases (null / absent)..expose_secret()call site re-audited — none feeds a log / print /format; each is a genuine consume boundary (hash, verify, DB write, HTTP form,
JWT verify).
Out of scope (follow-up #370)
The credential
blob(EC2 secret key / TOTP seed material) is also a secret but isoutside the maintainer's stated scope for this effort; left as a follow-up so it
isn't silently dropped.
🤖 Generated with Claude Code