Software Memory Database · Chapter 2 of 3

Audit Log and Hash Chain

Every security-relevant write to the Memory Database leaves an entry in the audit log. Each entry carries a SHA-256 hash linking it to the previous entry. The chain is verifiable from the Settings UI and from any external SQL client. Tampering with the database, removing rows, or reordering events breaks the chain at the point of interference and the verifier reports the exact row index where the break occurs.

6 min read
Applies to v2.0+
Last updated 2026-07-19
PUBLISHED

Every security-relevant write to the Memory Database leaves an entry in the audit log. Each entry carries a SHA-256 hash linking it to the previous entry. The chain is verifiable from the Settings UI and from any external SQL client. Tampering with the database, removing rows, or reordering events breaks the chain at the point of interference and the verifier reports the exact row index where the break occurs.

This chapter covers the algorithm, the verification workflow, the on-disk schema, and the interpretation of failure states.


What gets recorded #

Every entry has the shape

text
Id          GUID
EventType   "PackageBuilt" / "Distributed" / "IdentityRenamed" / …
EntityType  "PackageRecord" / "DistributionRecord" / "Settings" / …
EntityId    GUID of the affected entity
Actor       DOMAIN\user
ActedAt     UTC ISO-8601 with offset
PayloadJson optional JSON snapshot of the change (before/after for updates,
            full record for creates and deletes)
PrevHash    32 bytes — SHA-256 of the previous entry's RowHash, or 32×0x00
            on the genesis (first) entry
RowHash     32 bytes — SHA-256 over (Id, EventType, EntityType, EntityId,
            Actor, ActedAt, PayloadJson, PrevHash) with 0x1F as field separator

The current set of recorded event types:

EventType When it fires EntityType
PackageBuilt Build phase completes successfully PackageRecord
Distributed SCCM / Intune push completes (success or failure) DistributionRecord
IdentityRenamed Vendor or AppName edit on the History page SoftwareIdentity
NotesUpdated Notes field edited on a build record PackageRecord
PackageDeleted Delete on the History page PackageRecord
IdentityDeleted Orphan-identity delete SoftwareIdentity
HistoryCleared Clear-all-history confirm Database
DatabaseMoved Storage section's Move-database operation Database
DatabaseBackedUp Portable backup written (see chapter 680) Database
DatabaseRestored Portable backup restored Database
GdprExport Subject Access Request bundle written (see Enterprise chapter 820) GdprExport
PreCheckVerdict Compliance pre-check ran before a Distribute push PreCheck
ReportExported Pipeline Health PDF/CSV exported (see Enterprise chapter 880) Report
ReportPerActorToggled Settings → Reports → Per-operator-toggle flipped Settings

This list grows as new event types ship. New types do not need a schema migration — the column is free-form text.


How the chain is built #

The algorithm lives in AuditHashChain.ComputeRowHash and is shared by the writer, the backfill pass, and the verifier — a single source of truth so writer and verifier cannot drift apart.

For each entry the hasher accumulates:

text
Id          UTF-8 of "00000000-0000-0000-0000-000000000000"
0x1F        ASCII Unit Separator
EventType   UTF-8
0x1F
EntityType  UTF-8
0x1F
EntityId    UTF-8 of the GUID
0x1F
Actor       UTF-8
0x1F
ActedAt     UTF-8 of ISO-8601 round-trip format ("o")
0x1F
PayloadJson UTF-8, or a single 0x00 marker if NULL
0x1F
PrevHash    32 raw bytes

The Unit Separator was chosen because it does not occur in any of the field encodings:

  • GUIDs are hex with hyphens
  • EventType and EntityType are ASCII identifiers
  • Actor is DOMAIN\user (no control bytes)
  • ActedAt is digits, T, Z, +, -, :, .
  • PayloadJson is JSON, which forbids unescaped control bytes per RFC 8259

This avoids the canonical-JSON rabbit hole while still being injection-safe: an attacker who controls one field cannot collapse it into an adjacent field by injecting the separator.

The genesis row uses 32 zero bytes as PrevHash. Every later row's PrevHash equals the previous row's RowHash, computed in ActedAt, Id order.


Verifying the chain #

Three paths exist.

From the Settings UI #

Settings → Database → Storage health → Verify chain integrity. The button reads every audit row in chronological order, recomputes each hash, and reports one of:

  • Chain valid — N rows verified — every entry matches.
  • N rows verified · M need backfill — entries existed before the hash-chain shipped (v0.x or v1.0 pre-RC) and are awaiting an automatic backfill on next app start.
  • Chain broken at row #N (Id=…) — the recomputed RowHash does not match the stored RowHash, or the stored PrevHash does not match the previous row's RowHash. The break point is reported with the entry's Id, EventType, and ActedAt so the auditor can investigate.

MAS runs an automatic verification pass on every app start that persists the result under %LOCALAPPDATA%\MAS\audit-chain-status.json. The Diagnostics section shows the last-verified status as a compliance card ("Audit log integrity · last verified 2h ago: 1,247 rows OK") so a broken chain raises an alarm even on machines where Settings is rarely opened.

From the Diagnostics section #

Settings → Diagnostics → Audit log integrity card shows the same status the Storage button surfaces, plus the trigger (AppStart / Storage-Manual / Diagnostics-Manual) so it is clear whether the snapshot is from the unattended boot-time scan or an operator-initiated run.

When the verdict turns BROKEN the card displays a red Tamper-Alert banner with the broken row index and reason. The banner persists across section navigations until either the chain is repaired or the chain status file is cleared.

From an external SQL client #

The chain can be verified independently of MAS — useful for forensic review or for compliance teams that prefer their own tooling. The required inputs are documented in the algorithm section above. A reference implementation in Python is available in the MAS distribution repo at docs/reference/audit-chain-verify.py.


Interpretation of failure states #

Symptom Most likely cause Next step
Chain valid, N rows Nothing to do —
Chain valid, M need backfill App was upgraded across the hash-chain ship date; backfill pending Wait for the next app start to backfill automatically, or run the Verify button to trigger it
Chain broken at row #N Direct UPDATE / DELETE on the AuditEntry table, restore from a divergent backup, or column-level data corruption Identify the broken row's EntityType + EntityId, investigate why that row changed, document the finding for the audit trail
All rows verified, but the count is lower than expected Direct DELETE on the AuditEntry table — the chain stays self-consistent from the post-delete state because the verifier re-walks from row #0 Cross-reference against any earlier known-good count snapshots

For a backup-restore boundary case, see chapter 680-Portable-Backup-and-Restore §Audit chain after restore.


Performance #

Verification scans the AuditEntry table linearly in ActedAt, Id order. On a populated database with 50 000 audit rows the scan completes in under one second on commodity hardware. The auto-verify-on-start runs in a background task; the app window appears before the scan finishes and the result populates the Diagnostics section asynchronously.

Indexes on ActedAt (single column) and on (EntityType, EntityId) (composite) ship with migration 001. No additional indexing is required for chain verification.


What this does NOT protect against #

  • A privileged operator with direct SQLite access who replaces the entire AuditEntry table with a consistent but fabricated chain. The verifier sees a valid chain — it has no anchor outside the database to compare against. For higher assurance, periodically export the latest RowHash to an external immutable store (write-once medium, customer-managed key vault, third-party timestamping service).
  • Backup-restore boundary forgery. A restore replaces the chain wholesale. See chapter 680.
  • In-flight tampering during write. The chain detects post-hoc edits; it does not prevent malicious writes by an authorised actor at write time. The Actor column is the accountability mechanism for that case.

Up next · Software Memory Database 3 of 3
Portable Backup and Restore
The portable backup is a single .masdb file that contains every row in the Memory Database. The file is human-readable JSON, schema-versioned, and operator-readable without any MAS-specific tooling.
→