--- title: "The hash chain: how tamper detection works" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{The hash chain: how tamper detection works} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(regulog) ``` Every `regulog` entry is cryptographically linked to the entry before it. This means that any modification to any part of any entry — however subtle — breaks the chain at that point and is detectable by `verify_log()`. This vignette explains how the chain is constructed, what it detects, what it does not detect, and how to verify logs in a production setting. ## 1. The genesis record When `regulog_init()` is called, a genesis record is created immediately. It is not a log entry in the usual sense — it carries no user action — but its SHA-256 hash becomes the anchor for the entire chain. ```{r genesis} log <- regulog_init(app = "demo", version = "1.0", user = "analyst") cat("Genesis hash:", log$genesis_hash, "\n") cat("Last hash: ", log$last_hash, "\n") ``` The genesis hash incorporates the app name, version, and creation timestamp. Two sessions with the same app and version but different creation times will have different genesis hashes. ## 2. How entries are hashed Each entry hash is computed over a canonical string that includes every field of that entry plus the hash of the previous entry: ``` h_n = SHA256( entry_id | timestamp | app | app_version | user | type | | h_{n-1} ) ``` The pipe character `|` is the delimiter. Field values are concatenated in a fixed, deterministic order — this is what makes the hash reproducible during verification. Let us look at what this looks like in practice: ```{r first-entry} log_action( log, "approved", "dataset_v1", "All quality checks passed — dataset approved for analysis" ) entry <- log$entries[[1L]] cat("Entry ID: ", entry$entry_id, "\n") cat("Prev hash: ", entry$prev_hash, "\n") # = genesis hash cat("Entry hash: ", entry$entry_hash, "\n") ``` The `prev_hash` of the first entry matches the `genesis_hash`. The chain has begun. ```{r chain} log_action(log, "model_fit", "ANCOVA_v1", "Primary ANCOVA fitted per SAP") log_note(log, "Outlier in subject 042 retained per SAP section 8.3") cat("Entry 1 hash:", log$entries[[1L]]$entry_hash, "\n") cat("Entry 2 prev:", log$entries[[2L]]$prev_hash, "\n") cat("Match: ", log$entries[[1L]]$entry_hash == log$entries[[2L]]$prev_hash, "\n") ``` Each entry's hash is the `prev_hash` of the next. The chain is intact. ## 3. Verification `verify_log()` recomputes every entry hash from scratch and checks that: 1. The recomputed hash matches the stored `entry_hash` — confirms the entry content has not been modified 2. The stored `prev_hash` matches the preceding entry's `entry_hash` — confirms no entries have been inserted, deleted, or reordered ```{r verify-intact} verify_log(log) ``` The return value is always a list with structured fields: ```{r verify-result} result <- verify_log(log, verbose = FALSE) str(result) ``` ## 4. What tampering looks like ### 4a. Modifying an entry's content If any field — reason, action, timestamp, user — is changed after writing, the recomputed hash will not match the stored `entry_hash`. The entry fails step 1 of verification. ```{r tamper-content} # Simulate a reason being altered original_reason <- log$entries[[1L]]$reason log$entries[[1L]]$reason <- "ALTERED" result <- suppressWarnings(verify_log(log, verbose = FALSE)) cat("Intact: ", result$intact, "\n") cat("First broken: ", result$first_broken, "\n") cat("Error: ", result$errors[[1L]], "\n") log$entries[[1L]]$reason <- original_reason # restore ``` ### 4b. Deleting an entry If entry 2 is deleted, entry 3's `prev_hash` will no longer match entry 1's `entry_hash`. The chain fails step 2 at entry 3. ```{r tamper-delete} saved <- log$entries log$entries <- log$entries[-2L] # remove entry 2 result <- suppressWarnings(verify_log(log, verbose = FALSE)) cat("Intact: ", result$intact, "\n") cat("First broken: ", result$first_broken, "\n") log$entries <- saved # restore ``` ### 4c. Breaking the prev_hash directly ```{r tamper-hash} saved_prev <- log$entries[[2L]]$prev_hash log$entries[[2L]]$prev_hash <- paste(rep("0", 64L), collapse = "") result <- suppressWarnings(verify_log(log, verbose = FALSE)) cat("Intact: ", result$intact, "\n") cat("First broken: ", result$first_broken, "\n") log$entries[[2L]]$prev_hash <- saved_prev # restore verify_log(log, verbose = FALSE)$intact # confirm restored ``` ## 5. What the chain does NOT protect against The hash chain proves that entries have not been modified after writing. It does not: - **Authenticate the user** — `user` is a string; `regulog` does not verify that the person who set `user = "jsmith"` is actually that person. Authentication is the responsibility of the calling system (OS login, Shiny Server Pro, Posit Connect). - **Prevent future entries** — anyone with write access to the `.rlog` file can append new entries. The chain only covers what is written; it does not prevent additions. - **Encrypt the content** — `.rlog` files are plain JSON. Do not store sensitive data in log entries; store references (file names, IDs) instead. - **Protect from file deletion** — if the `.rlog` file is deleted, the audit trail is gone. Use standard file system controls and backups. ## 6. Verifying a file without a live session The `.rlog` file is self-contained. Verification does not require the original `regulog` object — it works directly from the file path: ```{r verify-file, eval = FALSE} # Can be run by a QC reviewer with no knowledge of the original session result <- verify_log("logs/trial001_audit.rlog") # Structured result for programmatic use if (!result$intact) { warning(sprintf( "Log integrity failure: %d error(s). First broken entry: #%d", length(result$errors), result$first_broken )) } ``` This is the intended QC workflow in regulated environments: the analyst runs the analysis and produces the `.rlog`, a reviewer verifies the file independently. ## 7. Working with persistent .rlog files When `path` is supplied to `regulog_init()`, entries are written to disk immediately — each `log_action()`, `log_change()`, etc. appends one JSON line. The file is append-only from `regulog`'s perspective. ```{r persistent, eval = FALSE} log <- regulog_init( app = "trial-analysis", version = "1.0.0", user = "jsmith", path = "logs/trial001_audit.rlog" ) log_action(log, "data_read", "adsl.sas7bdat", "Reading ADSL") # ↑ This line is written to disk immediately # The .rlog file at this point: # Line 1: {"entry_id":0,"type":"GENESIS",...} # Line 2: {"entry_id":1,"type":"ACTION","action":"data_read",...} ``` The genesis record is always line 1. Subsequent entries follow in order. Each line is a complete, self-contained JSON object. ## 8. The NDJSON format `.rlog` files are newline-delimited JSON (NDJSON). Each line is one entry: ```json {"entry_id":0,"timestamp":"2026-06-23T10:00:00.000Z","app":"trial-analysis","app_version":"1.0.0","user":"jsmith","type":"GENESIS","prev_hash":"0","entry_hash":"a3f8c2..."} {"entry_id":1,"timestamp":"2026-06-23T10:01:22.841Z","app":"trial-analysis","app_version":"1.0.0","user":"jsmith","type":"ACTION","action":"data_read","object":"adsl.sas7bdat","reason":"Reading ADSL","prev_hash":"a3f8c2...","entry_hash":"b7d94e..."} ``` This format was chosen deliberately: - **Human-readable** without specialist software - **Streamable** — tools like `jq` can filter entries without loading the whole file - **Append-safe** — no file rewriting needed; each entry is one line - **Portable** — plain text, no binary encoding ## 9. Archival and long-term storage For regulatory archival, export a signed CSV or JSON before storing: ```{r archival, eval = FALSE} # Signed CSV — chain_intact and verified_at stamped on every row export_audit_trail(log, format = "csv", signed = TRUE, path = "archive/trial001_audit_2026-06-23.csv" ) # Original .rlog — keep this too; it allows re-verification later file.copy( "logs/trial001_audit.rlog", "archive/trial001_audit_2026-06-23.rlog" ) ``` The signed CSV is human-readable and importable into any audit management system. The `.rlog` file allows the original hash chain to be verified at any future point using `verify_log()`. ## 10. Hash algorithm The default algorithm is SHA-256 (`hash_algo = "sha256"`). This is set at `regulog_init()` and stored with the session — do not change it after a `.rlog` file is in use, as verification would fail for any entries written with a different algorithm. SHA-256 is the standard for regulated environments. If your organisation requires a different algorithm, pass `hash_algo` to `regulog_init()` — any algorithm supported by `digest::digest()` is accepted.