Chain of Custody¶
A court-ready record of what happened to every piece of evidence the Companion stores: who collected it, when, from where, its SHA-256 and SHA-1, and every subsequent access, transfer or export.
The Activity Log answers what was done to this case. Chain of custody answers what happened to this artifact — the question you have to be able to answer if a case goes to court or in front of a regulator.
What gets recorded, and when¶
Nothing to switch on, and nothing to remember. Every artifact the Companion writes to disk is recorded automatically:
| Artifact | Recorded as |
|---|---|
| Screenshots from the browser extension | collected — with the page URL it came from, the capture trigger, and browser-extension as the collector |
| Imports (every format, including server-path imports of large files) | collected |
Files dropped in the case's drop/ folder |
collected, once a tool ingests them |
Files are recorded the moment they arrive, before any parser runs. Every import route writes a received entry first, holding the file name, its size, and its SHA-256 and SHA-1. A file the importer then rejects (wrong format, nothing parseable, empty) therefore still has a hashed entry: you can show that it was handed to the Companion, and match it against your own copy. The bytes of a rejected file are not stored. In the panel such an artifact reads received — not stored, and the report appendix says the same. An accepted import gets two entries — received, then collected — with the same hashes. If the received entry cannot be written, the import is refused with a 500 and no parser runs. A server file imported by path is hashed and imported from one open of the file; if it changes between the hash and the copy, the import is refused with a 409 ("file changed while it was being imported") and no copy is kept.
For an import that names a file on the server (import-file, import-mac-login-item), the Companion reads the file once more to hash it on arrival; for a very large file (a Plaso timeline) that adds a second pass of a few seconds. Screenshots, tool runs, the drop folder and MCP deliveries are still recorded when they are stored, not on arrival.
Whole-case imports are receipted on success only. Importing a .dfircase package or an "Archive to ZIP" file creates a new case, and there is no case to hold a chain until the archive opens. When the import succeeds, the new case's chain gets a received entry holding the archive's file name (as the dashboard sent it), SHA-256, SHA-1 and size, so you can match the case to the exact file you were handed, followed by a transferred entry recording where it came from. An import made through the API without a filename field is named .dfircase archive or ZIP archive instead. An archive that is rejected (wrong password, corrupt, tampered) leaves no custody entry. If the received entry cannot be written after a successful import, the case still imports and the server log records a warning.
Recording is hooked onto the case store's evidence-writing functions, not onto the individual import routes — so an import path added in a future version is covered from the day it ships, rather than silently skipping custody.
Exports append an exported event for every artifact in the case, re-hashed at the moment it leaves:
- Encrypted case archive (
.dfircase) - ZIP case archive
- Redacted case package
Report, STIX, IRIS and ClickUp exports carry conclusions rather than evidence, so they are left to the Activity Log.
Recording something by hand¶
Evidence the Companion never stored itself — a mounted disk image, output from an external tool, or a physical handover — is recorded through the API:
curl -X POST http://127.0.0.1:4773/cases/INC-1/custody \
-H 'content-type: application/json' \
-d '{"artifactPath":"/mnt/evidence/laptop.dd","collectedBy":"alice","source":"seized 2026-07-28","event":"collected"}'
event is one of collected, accessed, transferred, exported, received. An unrecognised value is rejected rather than quietly filed as a collection — a custody chain that silently relabels what happened is worse than one that refuses the entry. The Companion hashes the file itself; you cannot supply the hash.
The path must be absolute. It is read as given and may live outside the case directory, which is deliberate: evidence usually does. Two places are refused with 403: the Companion's own configuration file (.env, which holds the API keys) and other cases' folders in the cases root. This case's own files can still be recorded. A closed or archived case refuses new records until you reopen or restore it.
The chain¶
Records live in custody.jsonl inside the case's metadata/ folder, one JSON object per line. Each record carries:
| Field | Meaning |
|---|---|
artifactPath |
Where the artifact is. Stored relative to the case folder when it lives inside it, so archiving a case or moving the cases root does not invalidate the record |
sha256 |
The artifact's hash at the moment of this event |
sha1 |
The same bytes under SHA-1, taken in the same read pass, for lookup in tools that still key on it (VirusTotal, older tooling). It is a lookup aid, not tamper protection — verification relies on SHA-256. Absent on records written before it existed; those still verify |
bytes |
Size in bytes, on received entries |
event |
collected / accessed / transferred / exported / received (arrived, before parsing; the path is a received: marker, not a file) |
collectedBy, collectedAt, source, trigger |
Who, when, from where, and what caused it |
seq |
Position in this case's chain |
prevHash |
SHA-256 of the previous line in the file |
That last field is what makes the log a chain rather than a list. Editing or removing an entry breaks the link at the following line, so tampering is visible instead of silent.
Two things are deliberately not treated as tampering
Records written before a case had a chain carry no prevHash and are skipped rather than condemned. And a gap in seq is legal — a failed write burns its number by design, so numbers are never reused, only skipped. Only a seq that fails to advance is flagged, because that means a replay.
Verification¶
Two questions get asked, and both matter:
- Did the evidence change? Every stored artifact is re-hashed and compared (SHA-256, and SHA-1 when the record has one).
receivedentries have no stored file and are skipped, so a rejected import never shows as missing. - Did the log change? The chain is walked link by link, and its end is compared against the pinned head — the record count and the hash of the last line, saved next to the log (
metadata/custody-head.json) every time an entry is written. If entries are cut off the end, or the last entries are rewritten, the check reports ahead-regressionbreak. A log that is longer than its pin is fine: that is what an interrupted write leaves behind. Once a regression is detected, later entries are still recorded but the pin is not moved, so the break keeps being reported after every later append instead of being hidden by the next import.
Limits of the pinned head
Cases recorded before this check existed have no pin yet; they are pinned from their next custody entry and are not flagged until then. The pin sits in the same case folder as the log, so it catches sync-client rollbacks, partial writes and naive edits — not someone who deliberately rewrites both files. The signed manifest remains the record held outside the folder.
Checking one without the other misses a whole class of tampering: swapping a file leaves the chain intact, and rewriting who collected an artifact leaves every hash intact.
When it runs¶
| Trigger | Default | What it covers |
|---|---|---|
| When you open a case | On | Just that case, in the background, re-checked at most every 4 hours |
| Scheduled sweep | Off | Every case including archived ones, on a timer you set |
The on-open check is the default because it gives you a fresh answer at the moment you are actually relying on the evidence, without an idle install spending hours re-hashing cases nobody is looking at.
What the default does not cover
On-open verification cannot see corruption in a case nobody opens — you find out when you next open it. If you want unattended assurance across the whole store, switch the scheduled sweep on (see Settings).
Failures raise a warning in the server log and a notification on the affected case, and show up in Diagnostics:
-- Evidence integrity --
verifies: on case open (re-checked after 4h)
last verified 2h ago — all 1247 artifacts OK across 3 case(s)
Verifying by hand¶
The Chain of Custody panel (see Dashboard Panels) has a Verify now button that re-hashes the current case immediately and marks any failed artifact in red. The same check is available at GET /cases/:id/custody/verify, which returns both mismatches (evidence that changed) and chainBreaks (log entries that changed).
The signed manifest¶
custody-manifest.json lists every artifact with its full chain, and is signed with this installation's secret (HMAC-SHA256 — the same secret that signs case-unlock cookies).
It exists to close a gap the chain cannot close on its own: chopping entries off the end of the log leaves a shorter chain that verifies perfectly. The pinned head (see Verification) catches that at rest, but it lives in the same folder, which an attacker with write access controls. So the manifest records where the chain ends — the record count, the final seq, and the hash of the last line — and signs that along with the records.
Version 2 (current) adds sha1 to each artifact and lists receipt-only artifacts with "stored": false. Manifests written before this change are version 1 and still verify unchanged; a manifest with any other version is reported as unverifiable.
You get it in four places:
- In the report folder, written every time you generate a report
- Inside the encrypted case archive
- Inside the redacted case package — built over the redacted records, so it describes the appendix that package actually ships
- On demand at
GET /cases/:id/custody/manifest, or the Signed manifest link in the dashboard panel
What the signature does and does not prove
A shared-secret signature proves the manifest has not been altered since this installation signed it — so you can later prove what you sent. It does not let a recipient verify anything on their own: verification needs the instance secret, which they do not have. Treat the manifest as a seal you can check, plus a machine-readable index of the chain for them, not as proof they can independently validate. Signing with an external PKI or HSM would change that, and is deliberately out of scope.
In the report¶
The Chain of Custody appendix lists each artifact with its SHA-256 and SHA-1 and every event that touched it. It is a normal report section: reorder or switch it off in Settings → Report Templates, like any other. It is on in the Standard template and off in the Executive Brief, where a per-artifact table is operator detail rather than client-facing content.
What a redacted export keeps
In a redacted case package the appendix is redacted field by field rather than wholesale. Artifact hashes (sha256, sha1, prevHash), the ordinal and the event name survive intact, so a recipient can still check the chain against the evidence they hold — a SHA-256 reveals nothing about a file's contents, name or origin. Paths, source hosts and collector names are tokenized, and so is any field added to the record in future: only the five named above are exempt, so a new field is redacted by default rather than leaking by default.
API summary¶
| Endpoint | What it does |
|---|---|
GET /cases/:id/custody |
Every custody record for the case |
POST /cases/:id/custody |
Record an artifact by hand (see above) |
GET /cases/:id/custody/verify |
Re-hash everything and walk the chain; returns mismatches + chainBreaks |
POST /cases/:id/custody/verify |
Kick off the same check in the background; returns immediately |
GET /cases/:id/custody/manifest |
The signed manifest |