Password hash redaction policy for historical user rows #15
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
authkit#14PR 4 makes theuserstable temporal (entity_id+valid_from+valid_until). Per owner directive,password_hashrides the temporal row — every password change creates a historical row with the prior hash preserved, plus a new live row carrying the new hash.This raises a security question: historical password hashes should not stay readable indefinitely. They give an attacker who breaches the DB everything they need to brute-force every password a user has ever used.
What needs to ship
A redaction policy applied at password-change time. Owner suggested: blank the actual hash on the historical row, but keep the row so the change-history (timestamps, who changed it) remains auditable.
Concrete shape (proposed):
TemporalRepository<UserDto>::savedecides this is an UPDATE that closes the prior live row, run an extra step: on the historical clone, setpassword_hash = NULL(or to a fixed sentinel likeredacted).valid_from/valid_until/username/role/tls_cert_dnstay on the historical row — onlypassword_hashis redacted.SELECT username, valid_from, valid_until FROM users WHERE entity_id = X ORDER BY valid_fromshows the full change history (when did this user exist, when was their password rotated, when were they deactivated), but the hashes themselves only exist on the live row.Open questions
TemporalRepository<UserDto>directly via a specialisation/hook — clean but couples the generic decorator to a user-specific concern.RedactedFieldRepository<TDto>decorator that takes a list of "redact on close" field names — generic, composes cleanly aboveTemporalRepository.ConcreteUserRepository::save— hand-rolled, not reusable.tls_cert_dn? Same security argument — a leaked old cert DN is less catastrophic than a hash, but still a privilege-escalation footprint. Worth thinking about whether the same policy applies.password_hashpopulated. Does the migration redact them retroactively, or do we accept the legacy hashes (acknowledging they were already in plaintext orbcryptformat on the live row before the migration anyway)?Effort
Probably Small for option 2 (
RedactedFieldRepository) once the design is locked. The actual code is a thin decorator that introspects DTO fields by name and zeroes them out on the historical clone.Refs
oatpp-authkit#14(parent migration tracker, now closed).Agent Evaluation
Feasibility: Low risk. The decorator stack is already in place (PR 0 of authkit#14); a new
RedactedFieldRepository<TDto>slots aboveTemporalRepositorywithout touching anything that ships today. Field-by-name introspection on a DTO is straightforward via oatpps reflective property dispatcher (used bycloneDtoinTemporalRepository).Impact: High in the security sense, low in the operational sense. Brute-forcing one historical password hash gives an attacker a known-plaintext oracle for the users password rotation patterns and likely future passwords. Closing that off is a clean win and doesnt cost runtime performance (one extra field-write per password change, never on read paths).
Effort: Small. Maybe 3-4 hours of code: a new
repo/RedactedFieldRepository.hppheader, a couple of unit tests, README update, version bump.Recommendation: Accept. Strong case from a defence-in-depth standpoint, and the cost is genuinely small.
Implementation plan
repo/RedactedFieldRepository.hpp— generic decorator templated on<TDto, FieldNames…>. Onsave, when the innerTemporalRepositorydecides this is an UPDATE that closes the prior live row, hook in to set the named fields tonullptron the historical clone before its persisted. Mechanism: shadow theTemporalRepositorysave path with our own version, or expose a small "before-historical-save" hook on the temporal decorator that this layer plugs into. The hook is the cleaner shape — keeps redaction policy out of the temporal decorator.makeUserRepository:RedactedFieldRepository<UserDto, "passwordHash">(TemporalRepository<UserDto>(ConcreteUserRepository(udb))).Open design choices (decision needed below)
The three open questions in the issue body resolve as follows in this evaluation:
RedactedFieldRepository<TDto>). The hook-based approach above is the cleanest shape.tls_cert_dn? → Same redaction policy applies. Cert DNs identify which old credential was active; an attacker who breaches the historical DB shouldnt get a privilege-escalation map. Cheap to add, makes the security story coherent.Decision needed
Check one (edit this comment):
passwordHashonly — cleanest scope; defertls_cert_dnto a separate consideration.passwordHashANDtlsCertDn— single PR, full security story.Evaluated #15 (effort: Small; recommendation: Accept) — picked Option 2 (RedactedFieldRepository decorator) with A/B/C decision-needed for the redaction scope (passwordHash only / + tlsCertDn / discuss first).
Implemented #15 — RedactedFieldRepository landed in commit
52449e4, taggedv0.13.0. BothpasswordHashandtlsCertDnredact on historical rows; live row keeps its values. 14/14 tests pass.