Skip to content

feat(security): Wrap secrets with secrecy crate (#369)#912

Open
ymh1874 wants to merge 2 commits into
openstack-experimental:mainfrom
ymh1874:feature/369-secrecy-wrap-secrets
Open

feat(security): Wrap secrets with secrecy crate (#369)#912
ymh1874 wants to merge 2 commits into
openstack-experimental:mainfrom
ymh1874:feature/369-secrecy-wrap-secrets

Conversation

@ymh1874

@ymh1874 ymh1874 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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 config
invalid_password_hash_key, and the OIDC token-exchange bearer tokens — in
secrecy::SecretString, so they can never leak through Debug,
#[tracing::instrument], or accidental serialization. Secrets that must appear in
OPA 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-types pulls through every downstream crate.


Background

secrecy was already a workspace dependency with an established pattern
(database.connection: SecretString), and passwords were already wrapped at the
provider/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
SecretString from the moment it is deserialized off the wire (or read from the
DB) 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)

Secret Layers touched
OIDC oidc_client_secret api-types DTO → core-types (3 structs) → SQL driver conversions → token-exchange consumer. oidc_client_id stays plaintext (per maintainer).
User / auth passwords UserCreate / UserUpdate / UserPassword + the full nested AuthRequest tree, and the mirror core-types domain structs, are now SecretString from deserialize down. Exposure happens only at hash_password / verify_password / validate_password.
Config invalid_password_hash_key SecurityComplianceProvider (mirrors database.connection).
OIDC id_token / access_token TokenExchangeResponse bearer tokens, previously plaintext String in a Debug-deriving struct; exposed only at the JWT-verify boundary.

Redaction, not exposure

SecretString intentionally does not implement Serialize. For the DTOs that
must stay serializable (they are embedded in OPA policy input / audit payloads),
a serialize_with helper 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 the
create/update variants) serialize the domain/DTO structs straight into OPA
policy input
. Before this PR those payloads contained the plaintext
oidc_client_secret and user password. They now redact.

Security hardening — empty-password guard

Wrapping the password meant the per-DTO length(min = 1) validation could not be
kept: validator 0.20's custom unconditionally calls
ValidationError::add_param(&field), which requires the field to be Serialize
and secrets deliberately are not. Without that guard, on a default deployment
(no password_regex) an empty password would be accepted and stored (bcrypt
hashes an empty string fine).

Fixed by moving the non-empty check into the correct central layer —
SecurityComplianceProvider::validate_password — which runs on the wrapped value
for every write path (create / update / change). This is strictly more secure
than before (previously only create was guarded).

Design decisions / notes

  • Sea-ORM oidc_client_secret column stays plaintext String. Wrapping the
    DB column would require custom TryGetable / Into<Value> impls and would not
    stop SQL-parameter-level logging anyway; the DB write is the final consuming
    method, where the secret is unwrapped.
  • PartialEq / Default are dropped from the wrapped structs where nothing
    relies on them (matching the existing app-cred / k8s pattern), and
    re-implemented manually where tests do rely on them (IdentityProvider
    read model; UserPasswordAuthRequest), preserving the prior public contract.
  • OpenAPI unchanged. #[schema(value_type = String / Option<String>)] keeps
    every wrapped field documented exactly as before; the generated spec has zero
    structural diff
    vs main.
  • validator + SecretString are fundamentally incompatible for field-level
    length/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
  • Full unit/lib suite green (no regressions): api-types, core-types, config, core,
    keystone, and the SQL drivers.
  • OpenAPI: regenerated and diffed against main0 structural changes.
  • Adversarial redaction / breakage test suite added, covering:
    • #[serde(flatten)] extra + custom serialize_with + skip on UserCreate /
      UserUpdate (the exact struct serialized to OPA) — password redacted, extra
      preserved, via both to_string and to_value.
    • Deep-nested AuthRequest serialize redaction (password 4 levels deep).
    • OPA-path redaction for the user + IdP create/update DTOs.
    • Empty-password rejected without a regex (regression guard).
    • Debug redaction matrix across every wrapped type, incl. the config hash key.
    • IdP response DTO can never expose a client secret.
    • OIDC bearer-token Debug redaction; deserialize edge cases (null / absent).
  • Every .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 is
outside the maintainer's stated scope for this effort; left as a follow-up so it
isn't silently dropped.

🤖 Generated with Claude Code

@ymh1874 ymh1874 force-pushed the feature/369-secrecy-wrap-secrets branch from a9abce7 to 43b1cb5 Compare July 3, 2026 17:07
#[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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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}");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread crates/api-types/src/v3/auth/token.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this should be hidden as well

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread crates/api-types/src/v3/auth/token.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is also a secret

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — TokenAuth.id is now a RedactedSecret; it is exposed only at the authorize_by_token call boundary via expose_secret().

Comment thread crates/api-types/src/secret_serde.rs Outdated
// 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>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ehm, I am actually not sure you need this. Have a look at K8sAuthRequest. Maybe I am wrong though

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

now again the nearly same function as above

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@ymh1874 ymh1874 force-pushed the feature/369-secrecy-wrap-secrets branch from 43b1cb5 to 7131791 Compare July 3, 2026 21:52
@ymh1874

ymh1874 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. I reworked the secret handling around a single RedactedSecret newtype in core-types (redacts on Debug/Serialize, derives PartialEq/Serialize/Deserialize on the owning struct). That let me:

  • delete secret_serde.rs and the duplicated core-types serializer,
  • drop the hand-written PartialEq for IdentityProvider (it derives again, so new fields are picked up automatically),
  • wrap the remaining plaintext secrets (passcode, TokenAuth.id),
  • trim the repetitive docstrings and relax the redaction tests to only assert plaintext absence.

api-types now depends on core-types directly (previously optional) so both layers share the type; every existing consumer already enabled builder/conv, so the only new edge is cli-manage picking up the pure-types crate. Individual replies inline. cargo build/clippy --lib --tests/tests all green.

@ymh1874 ymh1874 requested a review from gtema July 3, 2026 21:53
@ymh1874 ymh1874 force-pushed the feature/369-secrecy-wrap-secrets branch from 7131791 to 29b7957 Compare July 4, 2026 10:26
derive_builder = { workspace = true, optional = true }
eyre.workspace = true
http = { workspace = true, optional = true }
openstack-keystone-core-types = { workspace = true, optional = true }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is for sure undesired, this pulls lots of very heavy dependencies to every crate that is only interested in API types

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread crates/core-types/src/secret.rs Outdated
#[derive(Clone, Default)]
pub struct SecretField(SecretString);

impl fmt::Debug for SecretField {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I also do not really get why you do this. SecretString already prevents exposure of the content which is why the crate is used

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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>
@ymh1874 ymh1874 force-pushed the feature/369-secrecy-wrap-secrets branch from 29b7957 to 56ac8df Compare July 4, 2026 21:01
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>
@ymh1874 ymh1874 force-pushed the feature/369-secrecy-wrap-secrets branch from 56ac8df to 2e15c47 Compare July 4, 2026 22:09
@ymh1874 ymh1874 requested a review from gtema July 5, 2026 10:18
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.

2 participants