07 — Tamper-Evident Logging
Status: Published
Last reviewed: August 2026
Scope
The app keeps an operational log of its own behaviour — logins, lock events, backups, re-keys, errors — and authenticates it so that changes to what it recorded are detectable.
This document describes how that authentication works and, more importantly, the precise boundary of what it establishes. A log that is called tamper-evident is making a claim, and it is worth knowing exactly how far the claim reaches.
Summary
| Value | |
|---|---|
| Log unit | a block — one app session, login to lock or logout (§1.1) |
| In-progress block | checkpointed to logs.draft, promoted on next launch (§1.1) |
| Block MAC | HMAC-SHA-256 under a dedicated log key |
| MAC input | the UTF-8 bytes of the hex string of SHA-256(logList JSON) |
| MAC covers | the block's log lines |
| MAC does not cover | blockNumber, time, hash, or the set of blocks |
| Log key | 256 random bits, independent of the vault root key |
| Blocks chained | no within a segment (§5); segments are linked (§1.2) |
| Log storage | a ring of segments: logs.txt + 2 sealed archives (§1.2) |
| Retention | 1 MB per segment, 90 days per archive, whole files only (§1.2) |
| Log contents | not encrypted (§6) |
| Comparison | constant-time, since 2026-08 |
1. What the log is
A file of newline-delimited JSON, one Block per line, appended to:
Block
├── blockNumber ← sequence number
├── time ← session start
├── hash ← SHA-256 of logList, hex
├── mac ← HMAC-SHA-256 of that hash, hex
└── logList
└── list[] ← { time, index, callingFunction, message }
A block covers one app session. Lines record the calling class and function, a timestamp, and a message.
There is a second file beside logs.txt — logs.draft — holding the block
currently being written to. See §1.1.
1.1 Session boundaries and the draft file
A block is closed — committed to logs.txt, and a new one started — only at a
real session boundary: logout, lock, inactivity timeout, PIN lock, or
AppLifecycleState.paused, the one lifecycle state that means the app was
actually backgrounded on both platforms.
It is specifically not closed on AppLifecycleState.inactive. That state is
the interruption signal, not the leaving signal: Face ID and the keychain
access-control prompt, Control Center, the app switcher preview, an incoming
call banner and permission dialogs all produce it while the app is still
frontmost and still holding its keys. Closing a block on each of them split one
session into a block per interruption — turning biometrics on from the Settings
screen reliably produced two blocks where the model above says one.
SettingsManager.isSystemPromptActive marks the intervals where an OS prompt is
raised over the app, and the lifecycle observers consult it before acting.
On inactive the open block is instead checkpointed: serialized, MAC'd and
written to logs.draft, replacing any previous draft, while the block itself
stays open and the session continues into it. The draft is written to a .tmp
sibling and renamed over the target, so an interrupted checkpoint leaves the
previous draft intact rather than a truncated one.
The draft is what makes early durability possible without segmenting. A
force-close from the foreground delivers inactive then detached and never
paused, giving no reliable window in which to close a block — but the
checkpoint already on disk survives it. On the next launch
LogManager.initialize promotes a surviving draft: it verifies the draft's MAC
and discards it on any failure rather than committing something §4 would later
reject; it checks that the draft's MAC is not already present in logs.txt,
which would mean the process died between committing the block and deleting the
draft; and otherwise it appends the draft as an ordinary block. saveLogs
deletes the draft after, never before, the block reaches logs.txt, so at
every instant at least one copy exists.
A draft is therefore never part of the verified log. It is either promoted to a
block or discarded, and §4 runs unchanged over logs.txt alone.
Two consequences for anyone reading the file:
- A block's
timeis when it was closed, not when the session began. The first line of itslogListcarries the earlier timestamp. logs.draft, if present, holds lines that are not yet inlogs.txt. A reader that ignores it will miss the tail of an unfinished session.
1.2 Segments, sealing and retention
The log is not one growing file. It is a small ring:
logs.txt segment 0, active — appended to, never rewritten
logs.1.txt most recently sealed
logs.2.txt older sealed
logs.draft in-progress block for the active segment (§1.1)
Sealing. When a commit pushes logs.txt past 1 MB, it is renamed to
logs.1.txt, each existing archive shifts down one slot, the oldest is
dropped, and a fresh empty logs.txt is opened. Sealing happens only at a
block boundary, so a segment always contains whole blocks and can be verified
on its own. Block numbers restart at 0 in each segment for the same reason.
Nothing edits a sealed segment. This is the rule the design exists to
enforce. An earlier version pruned expired blocks out of the middle of the
file, appended a synthetic prevHash/prevMac line to the blocks that
followed, recomputed their hashes and re-MAC'd them — and the result verified
clean. That is exactly the operation an attacker holding the log key would
perform to remove their own entries, so the log could not distinguish
legitimate maintenance from forgery. Retention now renames and deletes whole
files and never touches a block's bytes.
Linking across the seal. The first block of a new segment is a Genesis block, and it carries one extra line:
LogManager.rotateSegment
"sealed: logs.1.txt | blocks: 214 | prevHash: <hex> | prevMac: <hex>"
That is modelled on AWS CloudTrail's digest chaining, where each digest file records the previous digest's name and hash so a deleted file is detectable. A Genesis block without such a line means the chain starts there — a new install, or logs the user cleared — which is CloudTrail's "starting digest" case. Three states are therefore distinguishable:
| Genesis line | Named archive | Meaning |
|---|---|---|
| present | present and verifies | full retained history is intact |
| present | absent | history was truncated at that point, by retention or by deletion |
| absent | — | the chain starts here |
Note the limit: the line is inside the MAC'd logList, so it cannot be
altered undetectably, but nothing forces a verifier to find the file it names.
It makes truncation visible, not impossible — consistent with §5. Deleting
a segment and the segment naming it leaves nothing to notice.
How continuity is checked. LogManager.chainStatus() reads the continuity
line from each retained segment's Genesis block and compares it against what is
on disk, matching on prevMac and never on the recorded file name — a name is
a slot, and slots shift as the ring rotates, so what one Genesis block called
logs.1.txt becomes logs.2.txt at the next rotation. A MAC identifies a
block wherever it ends up, the same reasoning _promoteLogDraft uses to
recognise an already-committed draft.
Four states are reported:
| State | Meaning |
|---|---|
empty |
nothing committed yet — new install, or logs just cleared |
completeFromGenesis |
the oldest retained segment starts the chain, and every segment after it is joined to the one before |
truncated |
history is trimmed at the oldest end — the expected steady state once the ring has rolled over or retention has swept |
gap |
two retained segments sit either side of a hole |
gap is reported separately because retention cannot produce it — sweeping
always removes from the oldest end. A hole in the middle means something else
removed a segment.
The nearest gap is checked before the oldest-end trim, and the order is load-bearing. Once the ring has rolled over, the oldest retained segment always names a lost predecessor, so an oldest-end check matches first every time and a hole punched in the middle would never be reported at all.
Where it surfaces. Diagnostics shows a History row carrying the state and
the date contiguous history begins, kept separate from the Logs Valid badge:
validity answers has anything been altered, continuity answers is anything
missing, and §5 is explicit that per-block MACs cannot answer the second. A
gap additionally raises a Segment Missing chip. The log viewer shows a note
on the segment where the retained history begins, so reaching the top of the
list reads as a known boundary rather than as the end of the record.
Retention. Two rules, both at whole-file granularity:
- Size — the active segment seals at 1 MB. With two archives kept, the log directory is bounded at roughly 3 MB.
- Age — a segment whose oldest block is older than 90 days is sealed, and an archive sealed more than 90 days ago is deleted.
The two halves read different clocks, and the distinction matters:
Archives age on file mtime. A sealed segment is never written again and
rename preserves mtime, so an archive's mtime is exactly its seal time. An
mtime in the future is treated as a backwards clock and the archive is kept —
mtime is a retention basis here, never a security control, since anyone able to
backdate it can also simply delete the file.
The active segment ages on its oldest block's timestamp, not on mtime.
mtime is the last write, which is the wrong basis in three ways: every commit
pushes it forward, so a segment in regular use never ages out however old its
first entry is; _promoteLogDraft appends to the segment during initialize
before the staleness check runs, resetting it to launch time; and rotation
stamps a fresh mtime on the new empty segment. Blocks carry their own
timestamps, so the clock is read from those.
When the age rule is evaluated is equally load-bearing. It runs at launch,
and again at every session boundary in _saveLogsInternal, beside the size
check. Launch alone is not enough: LogManager.initialize runs once per
process — from LoginScreenViewModel.loadData, which fires from
LoginScreen.initState, and LoginScreen is the MaterialApp home:, built
once and never disposed. Mobile platforms keep apps suspended for days, so a
user who never force-quits would never reach a launch-time-only check and the
window would quietly go unenforced.
Together these bound an entry's age at roughly the window plus one session, which is what makes the archive sweep mean anything.
The granularity has a consequence worth stating: because retention deletes whole segments, an archive is kept until every block in it is past the window, so individual entries can outlive 90 days by as long as the segment took to fill. Reaching inside a segment to trim them is precisely what this design refuses to do.
2. The log key
A 256-bit random key, generated once per app installation and stored in the keychain — non-syncable, readable only while the device is unlocked.
It is not derived from the vault root key, and that is deliberate. Two consequences follow:
- The log survives a re-key. Rotating the vault's root key (doc 03) does not invalidate the log, because the log key is independent and is re-saved rather than regenerated. A log that became unverifiable every time the user rotated their key would be useless as a record.
- The log key is not protected by the master password. It sits in the keychain under ordinary protection. Anyone who can read the keychain can read it — and with it, forge log blocks that verify. The log's integrity is bounded by keychain access, not by knowledge of the password.
The key is created on first use and destroyed only by a full vault wipe.
3. How a block is authenticated
logJson = JSON of the block's logList
blockHash = SHA-256(logJson) → lowercase hex string
blockMac = HMAC-SHA-256(logKey, UTF-8(blockHash)) → lowercase hex string
Note the second step carefully: the MAC is taken over the text of the hex digest, not the 32 raw digest bytes. It is a consistent convention and it is what a reimplementation must match; hashing the raw bytes produces a different and incompatible value.
Both blockHash and blockMac are stored on the block.
4. What verification actually does
On load, for each block: re-serialize its logList, recompute the hash,
recompute the MAC, and compare it in constant time (doc 02 §8.2 covers why the
comparison matters). Any mismatch marks the whole log as unverified.
Verification is per segment. On launch only the active segment is verified — with the log split into segments this touches at most 1 MB rather than the whole history — and archives are verified on demand from the diagnostics screen, which reports across all of them.
That is the entire check. It is worth listing what is not part of it:
- The stored
hashfield is never compared against the recomputed one. It is recomputed as an input to the MAC, and the stored copy is decorative. blockNumberandtimeare outside the MAC input, which covers onlylogList.- The sequence of block numbers is not checked for gaps.
- Nothing relates one block to the next within a segment. Across segments, the Genesis continuity line (§1.2) does relate one to the last block of its predecessor, and that line is inside the MAC.
5. What the log proves, and what it does not
It proves: the log lines inside any block that verifies have not been altered since that block was written, by anyone without the log key.
It does not prove that the log is complete. Because nothing binds blocks together or to a count, an attacker with write access to the file can:
- delete whole blocks — every remaining block still verifies;
- truncate the file — the surviving prefix verifies;
- reorder blocks — no relationship between them is checked;
- renumber or re-time blocks —
blockNumberandtimeare outside the MAC.
So the log detects modification of recorded events. It does not detect removal of them — except across a segment seal, where the continuity line in each Genesis block makes a missing predecessor visible (§1.2). That narrows the gap; it does not close it. For an audit log this is the distinction that matters most, because the interesting attack is not editing an entry that says something happened — it is deleting it.
The material for a chain already exists. When the log is loaded, the app appends a line to each block recording the previous block's hash and MAC:
"prevHash: <hash>, prevMac: <mac>"
That line is inside logList, so it is covered by the block's MAC and cannot
be altered undetectably. What is missing is the verification step: nothing
parses those values and walks the chain. A verifier that did would close the
deletion and reordering gaps for every block written after this line was
introduced, without any format change. The steady-state append path does not
write the line, so it would need to be recorded there too.
This is the same shape as the gap in doc 02 §8.3 and doc 05 §7.2 — individually authenticated records with nothing authenticating the collection.
6. Logs are not encrypted
The log file is plaintext JSON. The MAC provides integrity, not confidentiality.
What that means in practice: anyone who can read the file learns the app's operational history — when the vault was opened and for how long, when backups and re-keys happened, when biometric or PIN unlock failed, when errors occurred, and which functions produced them.
The log records events, not vault contents: no passwords, no item values, no key material. But the timing and shape of a person's usage is itself information, and on a device where the log file is readable it is available without attacking anything.
7. Limitations and non-goals
7.1 Deletion and truncation are undetected. §5. This is the principal limitation.
7.2 The log key is not password-protected. §2. An attacker with keychain access can forge blocks that verify, which means the log is evidence against tampering by someone without that access — file-level modification, a corrupted copy, a sync conflict — and not against an attacker who has already compromised the device's keychain.
7.3 blockNumber, time and hash are unauthenticated. §4.
7.4 No confidentiality. Logs are anonymized §6.
7.5 Verification is all-or-nothing per load. A single failing block marks the log unverified; there is no per-block report of which portion is trustworthy beyond the block numbers recorded for failures.
7.6 Continuity reporting is advisory. §1.2. chainStatus() reports that a
named predecessor is absent; it cannot report a segment nobody named. An
attacker who removes the newest segments — the ones naming what came before —
leaves a chain that reports itself complete.
7.7 Retention is whole-segment. §1.2. An entry can outlive the 90-day window by as long as the session that follows the window's expiry — the segment is sealed at the next boundary, not at the instant the oldest entry crosses the line — and a sealed archive is deleted in full rather than trimmed.
7.8 The log is not a security control. Nothing in the vault's protection depends on it. It is a record, useful for diagnosing behaviour and noticing that something changed — and it should not be read as an access control or an intrusion detector.
8. Reproducing block verification
import hashlib, hmac, json, base64
log_key = base64.b64decode("<log key from the keychain>")
def verify_block(block: dict) -> bool:
log_json = json.dumps(block["logList"], separators=(",", ":"))
block_hash = hashlib.sha256(log_json.encode("utf-8")).hexdigest()
# the MAC covers the TEXT of the hex digest, not the raw digest bytes
mac = hmac.new(log_key, block_hash.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, block["mac"])
for line in open("logs.txt"):
if line.strip():
block = json.loads(line)
print(block["blockNumber"], verify_block(block))
The serialization has to match the app's exactly — the MAC is over the app's JSON text, so key order and separators matter, as in doc 02 §9.
Note what this loop cannot tell you: whether any block is missing. Every
block it prints True for is intact; the set it iterates over is whatever the
file happens to contain.
Change log
| Date | Change |
|---|---|
| 2026-08 | Active-segment age measured from its oldest block rather than file mtime, and evaluated at every session boundary rather than only at launch. Retention collapsed to one _logRetentionWindow constant. |
| 2026-08 | chainStatus() reads the continuity line back and reports empty / completeFromGenesis / truncated / gap in Diagnostics and the log viewer. Extends §1.2, adds §7.6. |
| 2026-08 | Log split into sealed segments with a bounded ring and whole-file retention; in-place pruning and re-MAC'ing removed. Adds §1.2. |
| 2026-08 | Blocks are closed only at session boundaries; inactive now checkpoints to logs.draft instead of ending the session. Adds §1.1. |
| 2026-08 | Block MAC comparison moved to constant time and empty MACs now fail closed. |
| 2026-08 | Initial version. Documents the block format, the independent log key, what per-block MACs establish, and the absence of chain verification. |