Hedronite · Curriculum · Sec · Ops · Wed 2026-11-04

What an operator can check without the key A sealed-line contract and a link walk

Verify a hash-chained audit log without the signing key: what the links prove, what an edited line hides, and why a tail cut needs a head stored elsewhere.

Lesson Class: Curriculum · O-Sec · rung A
Tested with: Nickel 1.18.0 · Nushell 0.116.1 · jq 1.7.1
Lab: lab/ · sh check.sh (12 checks) · no network
Source: NIST SP 800-53 Rev. 5 AU-9 · RFC 2104 · RFC 8785 · Nickel manual: Contracts · Nushell command reference · AWS CloudTrail User Guide · DOP-C02 task 6.3
Check the Links
Each line names the one before it, so a deleted or reordered line breaks a link anywhere in the file.
Say What You Cannot See
Without the key the walk cannot tell an edited line from an honest one, and it prints that fact on every run.
Store the Head Elsewhere
Links only point backwards, so only a head kept somewhere the writer cannot reach reveals a cut tail.
An exit code of 0 says the links hold. It does not say the log is true.

O-Sec · A · arc: self-healing-edge · langs: nickel, nushell · DOP-6.3

By Evan Eugene Pincham.

Concept

A deploy log is a record of what a deployer did: which version started, which was promoted, which was rolled back. If the process that writes the log, or anyone with write access to its disk, can also edit it, the log is a claim and not evidence. The companion lesson, "A deploy log that shows where it was edited, in Rust", wrote a sealer and a verifier to deal with that. This lesson asks a narrower question: what can an operator check about a sealed log without the secret key?

The question matters because an operator on call often has a copy of the log and not the key. The key lets someone forge a seal, so it should not be handed around.

Terms, defined where they first appear:

The data: a schema and a rule for bytes

The unsealed log has four fields per line: ts (a Unix timestamp), event, version, detail. Sealing adds two fields:

The rule for which bytes the mac covers is part of the schema, even though no schema language states it. The sealer serializes those five fields as JSON from a Rust struct with a fixed field order, using serde_json. That gives no whitespace and one stable byte string. The mac field itself is not covered, because it does not exist yet when the tag is computed.

RFC 8785 (JSON Canonicalization Scheme), section 1, says cryptographic operations "need the data to be expressed in an invariant format so that the operations are reliably repeatable". Section 3.2.3 requires object properties to be sorted. This lab does not implement that scheme. One Rust program writes and checks the bytes, so struct field order is enough. A second writer in another language would need that scheme or some other agreed byte rule.

What needs the key and what does not

QuestionNeeds the key?
Is every line well-formed JSON with the six fields in the right shape?No
Does each line's prev equal the previous line's mac?No
Does the last line's mac equal a head recorded elsewhere?No
Is each line's mac the correct HMAC of its content?Yes

The first three rows are what this lesson builds. The last row is the part of the morning verifier that only a key holder can run.

A contract checks shape. A mac of 64 well-formed lowercase hex characters can still be the wrong tag, and only the key can say. Everything in this lesson is a check on structure and on links. None of it checks truth.

The reading rule for the walk is this: exit 0 means the links hold, never that the log is true. The walk prints that sentence in its own output fields (mac_checked and cannot_see, shown below), so a dashboard that shows only the exit code cannot drop it without dropping the fields too.

Where this stands in the standards

NIST SP 800-53 Rev. 5, control AU-9 "Protection of Audit Information", says to "Protect audit information and audit logging tools from unauthorized access, modification, and deletion". Enhancement AU-9(2), "Store on Separate Physical Systems or Components", "helps to ensure that a compromise of the system being audited does not also result in a compromise of the audit records". That is the reason to record a head somewhere else. Enhancement AU-9(3), "Cryptographic Protection", mentions "signed hash functions using asymmetric cryptography". Our HMAC chain is symmetric, and the Other paths section returns to that difference.

Petal: AI (hypothetical, not run)

This lab has no model. The following is a hypothetical case only. Suppose an assistant were asked to summarize a deploy log. It would read text written by other processes. The detail field is a free string, and the damaged fixture shows a line replaced by thread main panicked at src/deployer.rs:41:9:, which is a message from some other program. Text of that kind could contain instructions aimed at the assistant instead of facts about the deploy.

The OWASP Gen AI Security Project, "LLM01:2025 Prompt Injection", describes indirect prompt injection as occurring "when an LLM accepts input from external sources, such as websites or files". Its mitigation 6 is "Segregate and identify external content". Its mitigation 5 is "Require human approval for high-risk actions". Applied to this hypothetical, the contract can say that detail is a string, but it cannot say the string is safe to obey. Hypothetical, not run.

Certification tie

DOP-C02 (AWS Certified DevOps Engineer - Professional, exam guide version 1.6), Domain 6 "Security and Compliance", Task Statement 6.3 is "Implement security monitoring and auditing solutions". Its knowledge list includes "Security auditing services and features (for example, CloudTrail, AWS Config, VPC Flow Logs, CloudFormation drift detection)", and its skill list includes "Configuring service and application logging (for example, CloudTrail, CloudWatch Logs)". This lab is a local exercise of one idea behind that task, tamper evidence for a log. It is not an AWS service and does not cover the exam objective on its own.

Worked example

Everything below was run on 2026-11-04 in a clean lab container with networking off, with Nickel 1.18.0 and Nushell 0.116.1. The lab ships six fixture files, all generated by the morning lesson's verifier under a fixed lab key:

The first line of sealed.log looks like this:

{"ts":1793632142,"event":"candidate_started","version":"v2","detail":"pid 997 port 18092","prev":"0000000000000000000000000000000000000000000000000000000000000000","mac":"7400e66ff307c86f7040fede835df7f294fb8089dffe917d0fc1f8b616447086"}

Part 1: a Nickel contract for one sealed line

# sealed-line.ncl: what a sealed deploy-log line must look like, as a contract.
# It checks shape only. It cannot check that `mac` is the right tag: that needs the key.
# The record is open (the trailing ..), so a newer writer's extra keys still pass.
let Uint = std.contract.from_validator (fun n =>
  if std.is_number n && std.number.is_integer n && n >= 0 then 'Ok
  else 'Error { message = "ts must be a non-negative integer" })
in
let Hex64 = std.contract.from_validator (fun s =>
  if std.is_string s && std.string.is_match "^[0-9a-f]{64}$" s then 'Ok
  else 'Error { message = "must be exactly 64 lowercase hex characters" })
in
{
  SealedLine = {
    ts | Uint,
    event | String,
    version | String,
    detail | String,
    prev | Hex64,
    mac | Hex64,
    ..
  },
  # The first line points at this value, because no line comes before it.
  genesis_prev = std.string.join "" (std.array.replicate 64 "0"),
}

Reading it from the top:

To apply the contract to real data, fixtures/sample.ncl lists the six lines and pipes the list into Array SealedLine:

let { SealedLine, .. } = import "../sealed-line.ncl" in
[
  { ts = 1793632142, event = "candidate_started", version = "v2", detail = "pid 997 port 18092", prev = "0000000000000000000000000000000000000000000000000000000000000000", mac = "7400e66ff307c86f7040fede835df7f294fb8089dffe917d0fc1f8b616447086" },
  { ts = 1793632142, event = "promoted", version = "v2", detail = "replaced v1 on port 18091", prev = "7400e66ff307c86f7040fede835df7f294fb8089dffe917d0fc1f8b616447086", mac = "39008333caaad03b6b1bb30c73c62aa282027ee99b7876623664b890f2918b84" },
  { ts = 1793632143, event = "candidate_started", version = "v3", detail = "pid 1034 port 18093", prev = "39008333caaad03b6b1bb30c73c62aa282027ee99b7876623664b890f2918b84", mac = "9d5e87a9f38904d1cb77c4e5b4598a8aaa29dcd4533174dd6df7a6efad85ec10" },
  { ts = 1793632144, event = "rolled_back", version = "v3", detail = "not ready after 1500 ms; v2 still serving on port 18092: true", prev = "9d5e87a9f38904d1cb77c4e5b4598a8aaa29dcd4533174dd6df7a6efad85ec10", mac = "8d4fcdcfad140115e80c97b15da3e1c18d89e59e04c58ea4b7d65d0a57968086" },
  { ts = 1793632145, event = "candidate_started", version = "v4", detail = "pid 1068 port 18094", prev = "8d4fcdcfad140115e80c97b15da3e1c18d89e59e04c58ea4b7d65d0a57968086", mac = "a270d6951aacbd0488ffdee13fa1a87162f74f057406aad11b31552d5e27a2b4" },
  { ts = 1793632145, event = "rolled_back", version = "v4", detail = "candidate exited with exit status: 78 before ready; v2 still serving on port 18092: true", prev = "a270d6951aacbd0488ffdee13fa1a87162f74f057406aad11b31552d5e27a2b4", mac = "7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd" },
]
| Array SealedLine

The checker also makes probe files of the form (import "probe.json") | SealedLine, which applies the same contract to a JSON file. These lines come from the start of the checker's transcript:

contract accepted 6 sealed lines
RESULT contract_accepts_sealed_lines PASS
contract refused a 63-character mac: must be exactly 64 lowercase hex characters
RESULT contract_rejects_short_mac PASS
contract refused a prev that starts with z: must be exactly 64 lowercase hex characters
RESULT contract_rejects_non_hex_prev PASS

The contract accepts the six real lines and refuses a mac that is one character short and a prev that starts with z. A shape check is useful for catching a truncated or mangled field. It says nothing about whether a well-formed mac is the right one.

Part 2: a Nushell link walk

Nushell is a shell whose pipelines carry structured values (tables, records, lists) instead of plain text. The walk below puts every physical line of the file into exactly one bucket: linked, link break, or unreadable. A physical line is whatever sits between two newline bytes, whether or not it is valid JSON.

# link-walk.nu: what an operator can check about a sealed deploy log WITHOUT the key.
# Usage: nu link-walk.nu <log> [--head <mac recorded elsewhere>]
# Every physical line goes in exactly one bucket: linked, link break, or unreadable.
# Exit 0 only if every line is linked and the tail (when a head is given) matches;
# 1 on any problem; 2 if the file cannot be opened.
# It never recomputes a mac, so it cannot see an edited line. It says so in its output.

# One rule for "a well-formed sealed line". sealed-line.ncl states the same rule as a contract,
# and check.sh feeds both the same probe records to catch them drifting apart.
def hex64 [v: any] { (($v | describe) == 'string') and ($v =~ '^[0-9a-f]{64}$') }

def valid-line [r: any] {
  if not (($r | describe) | str starts-with 'record') { return false }
  let cols = ($r | columns)
  if not (['ts' 'event' 'version' 'detail' 'prev' 'mac'] | all {|k| $k in $cols }) { return false }
  if ($r.ts | describe) != 'int' { return false }
  if $r.ts < 0 { return false }
  if not ([$r.event $r.version $r.detail] | all {|v| ($v | describe) == 'string' }) { return false }
  (hex64 $r.prev) and (hex64 $r.mac)
}

def classify [raw: binary] {
  # `decode utf-8` never fails, so decode, encode again and compare the bytes.
  let text = ($raw | decode utf-8)
  if ($text | encode utf-8) != $raw { return { ok: false, kind: 'not utf-8' } }
  if ($text | str trim | is-empty) { return { ok: false, kind: 'blank' } }
  let parsed = (try { { value: ($text | from json --strict) } } catch { null })
  if $parsed == null { return { ok: false, kind: 'not json' } }
  if not (valid-line $parsed.value) { return { ok: false, kind: 'schema' } }
  { ok: true, rec: $parsed.value }
}

def main [log: path, --head: string] {
  if not ($log | path exists) {
    print -e $"link-walk.nu: cannot open ($log)"
    exit 2
  }
  let genesis = (^nickel export --field genesis_prev ($env.FILE_PWD | path join 'sealed-line.ncl') | from json)
  let parts = (open --raw $log | into binary | bytes split 0x[0a])
  let parts = (if (($parts | is-not-empty) and (($parts | last | bytes length) == 0)) { $parts | drop } else { $parts })
  let rows = ($parts | enumerate | each {|p| (classify $p.item) | insert line ($p.index + 1) })

  # Walk once. A line is linked when its `prev` equals the last readable line's `mac`.
  # After a break the walk carries on from what the line claims, so one deleted line is one break.
  let walked = ($rows | reduce --fold { expected: $genesis, out: [] } {|row, acc|
    if not $row.ok {
      { expected: $acc.expected, out: ($acc.out | append { line: $row.line, bucket: 'unreadable', kind: $row.kind }) }
    } else if $row.rec.prev == $acc.expected {
      { expected: $row.rec.mac, out: ($acc.out | append { line: $row.line, bucket: 'linked', kind: '' }) }
    } else {
      { expected: $row.rec.mac, out: ($acc.out | append { line: $row.line, bucket: 'link break', kind: 'link' }) }
    }
  })
  let out = $walked.out
  let last_mac = $walked.expected
  let linked = ($out | where bucket == 'linked' | length)
  let breaks = ($out | where bucket == 'link break' | length)
  let unread = ($out | where bucket == 'unreadable' | length)
  let problems = ($out | where bucket != 'linked')
  let tail = (if $head == null { 'not checked (no --head given)' } else if $head == $last_mac { 'match' } else { 'MISMATCH (expected head ' + $head + ')' })

  print $"link-walk: ($log | path basename)"
  print $"lines: ($out | length)"
  print $"linked: ($linked)"
  print $"link_breaks: ($breaks)"
  print $"unreadable: ($unread)"
  print 'mac_checked: no (needs the key)'
  print 'cannot_see: an edited line, a forged line, a rewrite by someone who holds the key'
  print $"head: ($last_mac)"
  print $"first_break: (if ($problems | is-empty) { 'none' } else { let p = ($problems | first); 'line ' + ($p.line | into string) + ' (' + $p.kind + ')' })"
  print $"tail: ($tail)"
  if ($problems | is-not-empty) {
    print 'problems:'
    for p in $problems { print $"  ($p.line)  ($p.kind)" }
  }
  if ($problems | is-not-empty) or ($tail | str starts-with 'MISMATCH') { exit 1 }
}

Nushell details, each observed in the lab on Nushell 0.116.1:

== nu link-walk.nu bytes.log
link-walk: bytes.log
lines: 6
linked: 4
link_breaks: 0
unreadable: 2
mac_checked: no (needs the key)
cannot_see: an edited line, a forged line, a rewrite by someone who holds the key
head: 8d4fcdcfad140115e80c97b15da3e1c18d89e59e04c58ea4b7d65d0a57968086
first_break: line 3 (not utf-8)
tail: not checked (no --head given)
problems:
  3  not utf-8
  4  blank
exit=1

The two real lines after the damage still link, because the walk carries on from the last readable line.

The link walk reproduces the numbers from the morning verifier that need no key. These lines come from the checker's transcript:

clean sealed log: exit=0 lines=6 linked=6 link_breaks=0 unreadable=0 first_break=none
RESULT walk_clean_log PASS
deleted line 3: exit=1 lines=5 linked=4 link_breaks=1 unreadable=0 first_break=line 3 (link)
RESULT walk_deletion_named PASS
damaged log: exit=1 lines=6 linked=2 link_breaks=2 unreadable=2 first_break=line 2 (not json)
RESULT walk_damaged_totals PASS

For the damaged log, here is the full report the walk printed:

link-walk: damaged.log
lines: 6
linked: 2
link_breaks: 2
unreadable: 2
mac_checked: no (needs the key)
cannot_see: an edited line, a forged line, a rewrite by someone who holds the key
head: 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd
first_break: line 2 (not json)
tail: not checked (no --head given)
problems:
  2  not json
  3  link
  5  not json
  6  link

The six lines split as follows. Lines 1 and 4 are linked. Lines 2 and 5 are unreadable. Lines 3 and 6 are link breaks, because the line before each one was lost, so its prev points at a mac the walk never saw. The totals add up: 2 + 2 + 2 = 6. The checker asserts that sum from the report text.

Part 3: what the walk cannot see, shown by running

The edited fixture has line 4 rewritten from rolled_back to promoted. The links still hold, because the edit did not touch prev or mac. The walk exits 0:

edited line 4: exit=0 lines=6 linked=6 link_breaks=0 unreadable=0 first_break=none
edited line 4 says: mac_checked: no (needs the key)
RESULT walk_edit_invisible_and_says_so PASS

The walk did not catch the edit, and it says so itself: mac_checked: no (needs the key). It also prints the cannot_see line shown in the damaged report above. Only the morning verifier, with the key, recomputes line 4's HMAC and finds that it no longer matches.

A tail cut is the second blind spot. The first four lines of the log form a perfectly good chain, because every link points backwards. Nothing in the file says that two more lines were ever written. Without a head, the walk exits 0. With a head that the operator recorded somewhere else, it exits 1 and says why. Neither run needs the key:

tail cut, no head: exit=0 lines=4 linked=4 tail=not checked (no --head given)
tail cut, with head: exit=1 tail=MISMATCH (expected head 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd)
RESULT tail_cut_needs_head PASS

This is the point of AU-9(2) in practice. The head is a single 64-character string, so it is cheap to copy to a place the audited system cannot write to.

Part 4: two copies of one rule

The contract in sealed-line.ncl and the hex64 and valid-line functions in link-walk.nu state the same rule twice: the field list and 64 lowercase hex characters. Two copies can drift. The checker guards against that by feeding five probe records to both and failing if they ever disagree:

The walk counts a probe as accepted when it reports unreadable: 0. This is the checker's transcript:

agree valid: contract=accept walk=accept
agree short_mac: contract=refuse walk=refuse
agree bad_prev: contract=refuse walk=refuse
agree no_mac: contract=refuse walk=refuse
agree extra_key: contract=accept walk=accept
RESULT walk_agrees_with_contract PASS

The extra key passes in both, because the contract is open. To see the check do its job, lab/probe.sh also closes the contract (it drops the trailing ..) and leaves the walk alone. The extra-key record is then refused by the contract and accepted by the walk, and the check fails:

agree valid: contract=accept walk=accept
agree short_mac: contract=refuse walk=refuse
agree bad_prev: contract=refuse walk=refuse
agree no_mac: contract=refuse walk=refuse
agree extra_key: contract=refuse walk=accept
RESULT walk_agrees_with_contract FAIL

Pitfalls, as symptom and cause

Lab

The lab directory holds the starter versions of sealed-line.ncl and link-walk.nu, the five fixture logs, fixtures/sample.ncl, and check.sh, a POSIX shell checker. You need nickel and nu on your path. No network is used.

The starter files are deliberately weak:

Copy check.sh into the starter folder, so that it sits beside link-walk.nu, sealed-line.ncl and fixtures/, and run it from there:

sh check.sh

On the starter it prints this. Seven checks fail, and each FAIL line names what to fix:

contract accepted 6 sealed lines
RESULT contract_accepts_sealed_lines PASS
contract accepted a 63-character mac
RESULT contract_rejects_short_mac FAIL
contract accepted a prev that starts with z
RESULT contract_rejects_non_hex_prev FAIL
clean sealed log: exit=0 lines=6 linked=6 link_breaks=0 unreadable=0 first_break=none
RESULT walk_clean_log PASS
deleted line 3: exit=1 lines=5 linked=4 link_breaks=1 unreadable=0 first_break=line 3 (link)
RESULT walk_deletion_named PASS
damaged log: exit=1 lines=4 linked=2 link_breaks=2 unreadable=0 first_break=line 2 (link)
RESULT walk_damaged_totals FAIL
RESULT totals_add_up PASS
RESULT walk_damaged_lines_named FAIL
edited line 4: exit=0 lines=6 linked=6 link_breaks=0 unreadable=0 first_break=none
edited line 4 says: mac_checked: 
RESULT walk_edit_invisible_and_says_so FAIL
tail cut, no head: exit=0 lines=4 linked=4 tail=
tail cut, with head: exit=0 tail=
RESULT tail_cut_needs_head FAIL
agree valid: contract=accept walk=accept
agree short_mac: contract=accept walk=accept
agree bad_prev: contract=accept walk=accept
agree no_mac: contract=refuse walk=accept
agree extra_key: contract=accept walk=accept
RESULT walk_agrees_with_contract FAIL
missing file: exit=2
RESULT missing_file_exit_2 PASS
--- damaged.out
link-walk: damaged.log
lines: 4
linked: 2
link_breaks: 2
unreadable: 0
head: 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd
first_break: line 2 (link)
problems:
  2  link
  4  link

Your tasks, in this order:

  1. Fix the contract. Replace String on prev and mac with a hex validator built from std.contract.from_validator and std.string.is_match. Add the Uint check on ts. Keep the record open.
  2. Read bytes, not lines. Open the file with open --raw, split on 0x[0a], and send every piece through a classifier. A piece that does not parse becomes an unreadable row with a kind. It is never dropped.
  3. Apply the same rule in the walk. Check the six fields, their types, and the 64-hex rule on prev and mac, so the walk and the contract agree on all five probes.
  4. Say what was not checked. Print mac_checked: no (needs the key) and a cannot_see line.
  5. Take a head. When --head is given, compare it to the last mac and exit 1 with tail: MISMATCH on a difference. When it is not given, print tail: not checked (no --head given).

When all twelve checks pass, the transcript matches this one, from a clean lab container with networking off:

contract accepted 6 sealed lines
RESULT contract_accepts_sealed_lines PASS
contract refused a 63-character mac: must be exactly 64 lowercase hex characters
RESULT contract_rejects_short_mac PASS
contract refused a prev that starts with z: must be exactly 64 lowercase hex characters
RESULT contract_rejects_non_hex_prev PASS
clean sealed log: exit=0 lines=6 linked=6 link_breaks=0 unreadable=0 first_break=none
RESULT walk_clean_log PASS
deleted line 3: exit=1 lines=5 linked=4 link_breaks=1 unreadable=0 first_break=line 3 (link)
RESULT walk_deletion_named PASS
damaged log: exit=1 lines=6 linked=2 link_breaks=2 unreadable=2 first_break=line 2 (not json)
RESULT walk_damaged_totals PASS
RESULT totals_add_up PASS
RESULT walk_damaged_lines_named PASS
edited line 4: exit=0 lines=6 linked=6 link_breaks=0 unreadable=0 first_break=none
edited line 4 says: mac_checked: no (needs the key)
RESULT walk_edit_invisible_and_says_so PASS
tail cut, no head: exit=0 lines=4 linked=4 tail=not checked (no --head given)
tail cut, with head: exit=1 tail=MISMATCH (expected head 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd)
RESULT tail_cut_needs_head PASS
agree valid: contract=accept walk=accept
agree short_mac: contract=refuse walk=refuse
agree no_mac: contract=refuse walk=refuse
agree bad_prev: contract=refuse walk=refuse
agree extra_key: contract=accept walk=accept
RESULT walk_agrees_with_contract PASS
missing file: exit=2
RESULT missing_file_exit_2 PASS
--- damaged.out
link-walk: damaged.log
lines: 6
linked: 2
link_breaks: 2
unreadable: 2
mac_checked: no (needs the key)
cannot_see: an edited line, a forged line, a rewrite by someone who holds the key
head: 7f56f0dce49cc6f511ff7894fb32d2c23d884ea70d761ba73b69f80abff352dd
first_break: line 2 (not json)
tail: not checked (no --head given)
problems:
  2  not json
  3  link
  5  not json
  6  link

Scope, stated plainly:

Other paths

The keyless check in this lesson is a link walk plus a shape contract. Two other ways to do the same step follow: the same walk in jq, which shows how a silent filter loses lines, and a read-only mapping onto AWS CloudTrail log file integrity validation, which solves the "verify without being able to forge" problem with signatures instead of a shared key.

jq: the same link walk with fromjson?

jq is a command-line JSON processor. The script below does the same link walk over a log. It was run with jq 1.7.1 on the lab host, not inside the lab image, and it is not part of the checker.

#!/bin/sh
# jq-walk.sh: the same link walk in jq, for the "other paths" section. Not part of the gate.
# Usage: sh jq-walk.sh <log>   (run from the fixtures directory)
G=0000000000000000000000000000000000000000000000000000000000000000
echo "physical lines: $(wc -l < "$1" | tr -d ' ')"
jq -R -c --arg g "$G" 'fromjson?' "$1" | jq -s -c --arg g "$G" '
  reduce .[] as $r ({expected: $g, linked: 0, breaks: []};
    if $r.prev == .expected then .linked += 1 else .breaks += [$r.mac] end | .expected = $r.mac)
  | {parsed: (.linked + (.breaks | length)), linked, link_breaks: (.breaks | length)}'

Output:

== jq-walk.sh fixtures/sealed.log
physical lines: 6
{"parsed":6,"linked":6,"link_breaks":0}
== jq-walk.sh fixtures/damaged.log
physical lines: 6
{"parsed":4,"linked":2,"link_breaks":2}

On the clean log it agrees with the Nushell walk. On the damaged log the file has 6 physical lines and jq reports 4 parsed. The ? after fromjson suppresses the error, so the cut line and the stray message vanish without a word. The linked and link_breaks counts happen to match the Nushell walk's, but the two unreadable lines are in no bucket, and the invariant "lines = linked + breaks + unreadable" fails (6 against 4). The wc -l line on top is the only hint, and the script does not compare it with anything. A jq version that keeps the invariant has to count the lines it drops, for example by parsing each line inside try and emitting a marker on failure. That would be a variation on this script, not something this lesson ran.

AWS CloudTrail validate-logs (read-only mapping, not run)

AWS CloudTrail records API activity in an AWS account. This section maps its log file integrity validation onto the ideas above. Nothing was run in AWS for this lesson, and the lab does not use AWS. Everything here comes from the CloudTrail User Guide pages named in the citations.

What CloudTrail does:

What carries over from the lab:

This labCloudTrail
prev points at the previous maceach digest holds the previous digest's signature
the head, recorded elsewheredigest files, stored in a separate folder
unreadable and link rows that name the damagea named time range that cannot be validated

What changes is the kind of secret. CloudTrail signs with a private key, and anyone with the matching public key can verify. They cannot forge a digest. The lab's HMAC chain uses one shared secret for both sealing and checking, so anyone who can verify can also forge. That is why the keyless walk in this lesson can only check links, and why NIST AU-9(3) names "signed hash functions using asymmetric cryptography" as a cryptographic protection. The cost of the asymmetric design is key management on the signing side. The lab does not build it.

The guide's remark about local disk is the same limit as the walk's blind spots. A copy of a log on your laptop is outside what the service can vouch for, and the walk's cannot_see line states the matching limit for the lab.

If you write the key-holder side in Go instead of Rust, the standard library's crypto/hmac documents Equal, which "compares two MACs for equality without leaking timing information", and warns that receivers "should be careful to use Equal to compare MACs in order to avoid timing side-channels". The Rust hmac crate's verify_slice serves the same purpose, and its finalize returns a CtOutput, "a thin wrapper around array of bytes for providing constant time equality check". No Go was run for this lesson. The keyless walk compares public hex strings, so a plain equality test is fine there.

Citations