|
| 1 | +# ADR-0100: Credential Field Channels — `secret` (encrypted) and `password` (masked) |
| 2 | + |
| 3 | +- **Status**: Accepted |
| 4 | +- **Date**: 2026-07-18 |
| 5 | +- **Issue**: #2036 (found via #2033 / #2025 / #2028 field-type round-trip work) |
| 6 | +- **Relates to**: ADR-0077 (authoring-surface boundary), ADR-0078 (no silently |
| 7 | + inert metadata), ADR-0069 (enterprise authentication hardening) |
| 8 | + |
| 9 | +This ADR unifies the two credential-bearing field types under one record. The |
| 10 | +`secret` channel had shipped without its own ADR — code comments referenced a |
| 11 | +"secret field channel" ADR that never existed; this document is that home. The |
| 12 | +`password` masking decision (#2036) is the new material and is documented |
| 13 | +alongside it, because the two types share the read mask and only make sense when |
| 14 | +contrasted. |
| 15 | + |
| 16 | +## Context |
| 17 | + |
| 18 | +ObjectStack has three places a credential can live, and they must not be |
| 19 | +confused: |
| 20 | + |
| 21 | +1. **`secret`** — a reversible machine credential (DB password, API key, token) |
| 22 | + authored on any object. Already implemented: encrypted at rest, masked on |
| 23 | + read, decryptable only through a privileged path. |
| 24 | +2. **`password`** — a field type authors reach for on custom objects. Its |
| 25 | + *intended* association (one-way hashing) belongs to the auth subsystem, but a |
| 26 | + `password` field on a **non-auth** object never touched that subsystem. |
| 27 | +3. **Auth-subsystem credentials** — better-auth's identity tables |
| 28 | + (`sys_account.password`, a hashed `text` column), one-way hashed off the |
| 29 | + generic CRUD path entirely. |
| 30 | + |
| 31 | +The bug (#2036): a `password`-typed field on a non-auth object (e.g. |
| 32 | +`showcase_field_zoo.f_password`) round-tripped **plaintext** through the generic |
| 33 | +CRUD engine — neither hashed nor masked. This is a low-code platform where field |
| 34 | +types are author-driven (often by an AI); someone modeling a `password` field |
| 35 | +reasonably expects credential-grade handling and silently got plaintext storage |
| 36 | +and plaintext reads, a runtime/security trap the static gates do not catch. |
| 37 | + |
| 38 | +The issue framed four options: (1) mask on read like `secret`; (2) hash on write |
| 39 | +in the generic path; (3) an author-time guard; (4) document as auth-only. Option |
| 40 | +2 is the wrong fit — one-way hashing only makes sense for credential |
| 41 | +*verification*, which a non-auth object never does, and doing it in the engine |
| 42 | +would stand up a second, unmanaged credential store that violates the |
| 43 | +"auth subsystem owns credentials" boundary. Option 4 leaves the silent trap in |
| 44 | +place. This ADR adopts **1 + 3** for `password`, and records the pre-existing |
| 45 | +`secret` channel it now sits beside. |
| 46 | + |
| 47 | +## Decision |
| 48 | + |
| 49 | +### A. The `secret` channel (records existing behavior) |
| 50 | + |
| 51 | +A `secret` field is **reversible and encrypted at rest**: |
| 52 | + |
| 53 | +- **Write** — the plaintext is wrapped by the registered `ICryptoProvider`, |
| 54 | + persisted as a `sys_secret` row, and replaced on the business row by an opaque |
| 55 | + `secret:<id>` ref. Cleartext never reaches the business table. |
| 56 | +- **Read** — the ref is masked to `SECRET_MASK` (`••••••••`) on the generic path |
| 57 | + (`find`/`findOne`/`$expand`); an unset secret reads back `null`. |
| 58 | +- **Fail-closed** — writing a secret value with no `CryptoProvider` registered, |
| 59 | + or no reachable `sys_secret` store, THROWS rather than persist cleartext. |
| 60 | +- **Privileged read** — `resolveSecret(ref)` is the only sanctioned way back to |
| 61 | + plaintext (e.g. a datasource connection binder); it is never on the generic |
| 62 | + read path. |
| 63 | + |
| 64 | +### B. The `password` channel (new, #2036) |
| 65 | + |
| 66 | +A `password` field on a generic (non-`better-auth`) object is **plaintext at |
| 67 | +rest but masked on read**: |
| 68 | + |
| 69 | +1. **Masked on read** — masked to `SECRET_MASK` in `find`/`findOne` (and |
| 70 | + `$expand`, which re-enters `find`), exactly like `secret`. |
| 71 | +2. **Plaintext at rest, by design** — **not** encrypted, **no** `sys_secret` |
| 72 | + row, **no** `CryptoProvider` required. Masking is a read-path transform only. |
| 73 | + This keeps the change minimal and avoids a second credential store. Authors |
| 74 | + who need reversible encryption-at-rest should use `secret`. |
| 75 | +3. **Echoed-mask write guard** — because a read now returns `SECRET_MASK`, a |
| 76 | + client that reads a record and PATCHes it back would otherwise overwrite the |
| 77 | + stored value with the literal mask. The write path drops any masked field |
| 78 | + (secret or password) whose incoming value equals `SECRET_MASK`, so an |
| 79 | + unchanged round-trip is a no-op. Accepted cost: the literal string |
| 80 | + `••••••••` cannot itself be stored as a password via an echoing client. |
| 81 | +4. **`managedBy: 'better-auth'` exemption** — the auth subsystem reads its |
| 82 | + identity rows *through* the engine's `find`/`findOne`, so masking a credential |
| 83 | + column there would break login. Objects marked `managedBy: 'better-auth'` are |
| 84 | + exempt from password masking. Today this is a safety net, not load-bearing: |
| 85 | + no shipped identity object even declares a `password`-typed field |
| 86 | + (`sys_account.password` is a hashed `text` column), pinned by a |
| 87 | + platform-objects test so retyping it becomes a deliberate decision. |
| 88 | +5. **Non-fatal author-time warning (ADR-0077/0078)** — `ObjectSchema.create()` |
| 89 | + emits a `console.warn` (deduped per object name) when a `password` field is |
| 90 | + declared on a non-`better-auth` object, steering authors to `Field.secret` |
| 91 | + for reversible machine credentials or to the auth subsystem for login |
| 92 | + credentials. It is a *warning*, not a build error: `password` now has a |
| 93 | + defined generic-path contract (so ADR-0078 does not compel an error), and the |
| 94 | + field-zoo example intentionally exercises every field type — a hard error |
| 95 | + would be self-inflicted breakage. Raw `.parse()` stays silent, since it also |
| 96 | + loads persisted metadata and `create()` is the authoring surface (ADR-0077). |
| 97 | + |
| 98 | +### C. Shared mechanism |
| 99 | + |
| 100 | +Both channels share one read-mask collector — `collectMaskedReadFields` |
| 101 | +(`packages/objectql/src/secret-fields.ts`): every `secret` field, plus every |
| 102 | +`password` field on a non-`better-auth` object. `maskSecretFields` (read) and the |
| 103 | +echoed-mask drop (write) in `engine.ts` both consume it, so the better-auth |
| 104 | +exemption lives in exactly one place. `SECRET_MASK` is the single mask constant |
| 105 | +for both. |
| 106 | + |
| 107 | +## Consequences |
| 108 | + |
| 109 | +- Edit forms that prefill from a read now show the mask for `password` fields — |
| 110 | + identical to the existing `secret` UX. Unchanged-value saves are protected by |
| 111 | + the echoed-mask guard (B3). |
| 112 | +- The generic read path no longer leaks credential plaintext for `password` |
| 113 | + fields; the field-zoo HTTP round-trip pins this (`f_password` upgraded from |
| 114 | + `present` to `masked`). |
| 115 | +- The dangling "secret field channel" references in `field.zod.ts` and |
| 116 | + `secret-fields.ts` now resolve to this ADR. |
| 117 | + |
| 118 | +## Non-goals / follow-ups |
| 119 | + |
| 120 | +- **`aggregate()` masking gap.** `aggregate()` masks neither `secret` nor |
| 121 | + `password` — a pre-existing gap for `secret`. Post-hoc masking of aggregate |
| 122 | + output would corrupt group keys; the correct fix is to *reject* aggregations |
| 123 | + that reference a credential field. Tracked in #3171, not addressed here. |
| 124 | +- **Hashing / verification for authored `password` fields** — explicitly out of |
| 125 | + scope (B2). Credential verification belongs to the auth subsystem. |
0 commit comments