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.txtlogs.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 time is when it was closed, not when the session began. The first line of its logList carries the earlier timestamp.
  • logs.draft, if present, holds lines that are not yet in logs.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 hash field is never compared against the recomputed one. It is recomputed as an input to the MAC, and the stored copy is decorative.
  • blockNumber and time are outside the MAC input, which covers only logList.
  • 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 blocksblockNumber and time are 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.