AUTH-ADR-0031 — Explicit Domain State for Security-Sensitive UI
Date: 2026-05-16 Status: Accepted Decision authority: Attomus Security Architecture Triggered by: Security post-mortem (stale OTP display after session lock; orphaned secret state)
Context
Two separate bugs — one in Android, one discovered during iOS CREST review — shared the same root cause: nullable or optional types were used to represent multiple distinct security-relevant states simultaneously, and display/action code could not reliably distinguish between them.
Android (stale OTP, a9cb76a7): AccountCard derived displayedCode from codeState?.code.
A null codeState meant either “session locked” or “account provisioning failed” — two very
different states. When the session lock path cleared sessionUnlocked but a background timer had
not yet cleared codeStateByAccountId, the card displayed and allowed copying of cached OTP codes
in a nominally locked state.
iOS (vault boundary regression, c571c88a): SecretVault.exportForBackup() returned a
BackupExportMaterial struct with public var plaintext: Data. The call site in
BackupExportView treated the presence of this struct as confirmation that export was authorised
and safe to hand to the encryption layer. In reality the plaintext had already left the vault
boundary — the struct itself was the security failure, not a missing nil check.
In both cases, nullable or loosely-typed values were used as a proxy for security state, and code downstream of that proxy made unsafe assumptions.
Decision
Security-sensitive UI and domain code must use explicit sealed state models, not nullable or boolean proxies.
Rule 1 — Account display state is an explicit enum
The authoritative state type for an account’s code display is a sealed class / enum with exactly three cases:
Locked // Session is not active; no code may be shown or copied
Unavailable(reason: String) // Session is active but key retrieval failed
Available(codeState: CodeState) // Session is active and code is ready
Locked and Unavailable are separate states. Displaying “Unavailable” when the session is
locked is a security failure, not just a UX error.
Display and copy actions are gated on pattern-matching this enum at the final render point, not on upstream boolean flags or null checks alone.
Rule 2 — Vault outputs are sealed; plaintext never crosses the vault boundary
Functions inside SecretVault (iOS) / AndroidSecretVault (Android) that produce backup
export data must return only encrypted bytes, never plaintext structs. Any struct that
contains plaintext: Data or secretBytes: ByteArray as a public field is a vault boundary
violation regardless of whether the caller intends to encrypt it next.
The correct pattern is closure-based: the vault calls the encryption closure while holding its state lock, and returns only the encrypted result.
Rule 3 — Clearing state is a defence-in-depth measure, not the primary gate
Actively clearing codeStateByAccountId / equivalent on session lock transitions is correct
and should be retained. However, display and copy code must also gate at the final render/copy
point independently of whether the clearing has run. The assumption is that stale state may
always exist; the gate must hold regardless.
Consequences
AccountCard(Android) and equivalent iOS code must be updated to use the three-case state enum rather than deriving state fromcodeState?.codeandsessionUnlockedseparately.BackupExportMaterial(iOS) is deleted.SecretVault.exportForBackupaccepts an encryption closure and returns onlyBackupExportPackage(encrypted bytes + metadata).- All new security-sensitive UI state in both apps follows the explicit enum pattern before merge.
- Code review checklist: any
?.or== null/??operating on security-sensitive display state is a review flag requiring justification.
References
- Android security-review finding
a9cb76a7— stale OTP codes after session lock - iOS security-review finding
c571c88a—BackupExportMaterialvault boundary regression