Attestation, keys and the chained log¶
Signing keys, the JWKS the broker publishes, and the hash-chained audit and attestation logs. The chained log fails closed: if it cannot prove integrity, the broker stops rather than append to something it cannot vouch for.
Key management over HTTP¶
Both routes need the keys capability. curl examples assume the
scratch broker on 127.0.0.1:8001 and
export NAUTILUS=http://127.0.0.1:8001.
| Message | Status | Line | Meaning |
|---|---|---|---|
reviewer is required (no control characters) |
400 | fastapi_app.py:1092 |
POST /v1/keys/rotate needs reviewer in the body. Control characters are stripped, so a value made only of them reads as absent. |
reviewer and reason are required (no control characters) |
400 | fastapi_app.py:1138 |
POST /v1/keys/{kid}/revoke needs both. |
kid must be a UUID |
400 | fastapi_app.py:1131 |
The {kid} path segment is not a UUID. Take it from /v1/keys/jwks.json. |
kid {kid!r} not found |
404 | fastapi_app.py:1156 |
No key with that id in the ring. |
curl -s -X POST "$NAUTILUS/v1/keys/rotate" \
-H 'X-API-Key: govern-key' -H 'Content-Type: application/json' -d '{}'
curl -s -X POST "$NAUTILUS/v1/keys/not-a-uuid/revoke" \
-H 'X-API-Key: govern-key' -H 'Content-Type: application/json' \
-d '{"reviewer":"ops","reason":"test"}'
Rotation and revocation are also refused with HTTP 409 carrying the underlying exception
text (fastapi_app.py:1096-1100 for rotation, :1126-1136 for revocation) — most often:
kid {kid!r} is the current primary; rotate first, then revoke¶
nautilus/core/broker.py:2010. Revoking the primary would leave nothing to sign with. Call
POST /v1/keys/rotate first, then revoke the now-superseded kid.
Both routes answer 503 Broker not ready (fastapi_app.py:1084-1087 and :1106-1109) when
app.state.broker is unset, and raise
session tokens are disabled (session_tokens.enabled: false) when tokens are off — see
session-tokens.md.
The key ring¶
Key {entry.kid!r} has no private key (revoked)¶
ValueError, nautilus/attestation/key_ring.py:259-262. Something asked a revoked key to
sign. Private material is dropped on revocation, deliberately; only verification survives.
Expected Ed25519PrivateKey / Expected Ed25519PublicKey¶
TypeError, nautilus/attestation/key_ring.py:264 and :273. A PEM file at
attestation.private_key_path loaded successfully but is not Ed25519 — an RSA or EC key, or a
public key where a private one was expected. Nautilus signs with Ed25519 only.
attestation is disabled¶
RuntimeError, nautilus/core/broker.py:3919. An attestation API was called while
attestation.enabled is false. Enable it, or stop calling that API.
Chained log configuration¶
All raised at startup while building sinks.
attestation.sink.chained requires attestation.enabled with a signing key¶
ValueError, nautilus/core/broker.py:1623-1625. A chained sink signs every line, so it
needs attestation.enabled: true.
audit.chained requires attestation.enabled with a signing key: each chained line carries a JWS, and there is nothing to sign with¶
ValueError, nautilus/audit/sink.py:57-62. Same for the audit log. build_audit_sink is
the one place audit.chained becomes a sink, so the broker, the CLI's governance commands and
the rule-validation pipeline all fail here identically.
audit.chained cannot append to the existing chain at {audit_path} with an auto-generated signing key: attestation.private_key_path is unset, so this process signs with a key the lines already on disk were not signed by, and every request would fail closed on a log that reads as corrupt. Set attestation.private_key_path to the key that wrote them, or start a new chain at a new audit.path.¶
ValueError, nautilus/audit/sink.py:63-72. The most common chained-log failure on a
second boot: the first run generated an ephemeral key, so the lines on disk cannot be extended.
Fix. Set attestation.private_key_path to a persistent key — before the first chained boot,
ideally — or point audit.path at a new file.
The warning that precedes it by one run (nautilus/audit/sink.py:74-78):
audit.chained is on with an auto-generated attestation key: this chain is signed by this
process only and the next boot will refuse to append to it. Set attestation.private_key_path
to keep it.
Single-writer enforcement¶
{subject} is already open for writing by {holder}. A hash chain admits exactly one writer: a second one interleaves into corruption that verify_chain cannot distinguish from tampering. {remedy}¶
SinkAlreadyLockedError (nautilus/core/attestation_sink.py:110), raised by
take_writer_lock at :131-135.
Interpolates. {subject} — the chained file. {holder} — the process already holding it.
{remedy} — what to do, supplied by the caller.
Means. Two writers were pointed at one chained log — usually two brokers, but a CLI
governance command run beside a serving broker reaches the same lock and reports it as
ERROR: this decision cannot be recorded, so it will not be taken.
nautilus adapters schema-ack reaches it too — it audits the override it records — and reports
it as ERROR: this acknowledgement cannot be recorded, so it will not be made.
Two interleaved writers produce a chain that fails verification identically to a tampered one,
so the second writer is refused.
Fix. Give each replica its own audit.path / attestation.sink.path, or run one writer.
The lock is an flock, which the kernel drops when the holder exits, so a leftover <path>.lock
file after a crash blocks nothing and does not need clearing.
emit on closed ChainedFileAttestationSink¶
ValueError, nautilus/core/attestation_sink.py:244. A record was emitted after close().
When embedding Nautilus, this means the broker was closed while work was still in flight — see
library.md.
Forensics offsets¶
OffsetsCorruptError (nautilus/forensics/offsets.py:24) — the resume file for the handoff
worker did not read back as it was written.
What the caller sees, for all seven. These have no HTTP route and no CLI command:
nautilus/forensics/handoff_worker.py:197 calls ProcessedOffsets.load() and does not catch it,
and run_worker is a library entry point. The exception propagates to whatever process runs the
worker; a plain script exits 1 with a traceback ending in OffsetsCorruptError: <message>.
The audit log itself is untouched — nothing has been read yet when load() fails, and nothing has
been dropped when save() refuses.
The first six all mean the same thing operationally: the file was hand-edited, truncated, or written by a different version. Delete it and the next run re-scans the audit log from byte 0. That is safe — it re-reads, it does not re-emit, because the seen-hash set is rebuilt as it goes.
unreadable offsets file {path}: {exc}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:75. {path} is the offsets path as
given to load(); {exc} is the OSError or json.JSONDecodeError underneath, which carries the
line and column of the first bad byte.
Means. The file exists but could not be read or parsed as JSON. A truncated write from a killed process is the common cause; the writer is atomic (temp file plus rename), so this normally means something outside Nautilus touched it.
Status. Uncaught OffsetsCorruptError out of run_worker → exit 1 in a plain script.
Fix. rm the file.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
p.write_text("{not json")
try:
ProcessedOffsets.load(p)
except Exception as exc:
print(f"{type(exc).__name__}: {str(exc).replace(str(p), 'offsets.json')}")
PY
OffsetsCorruptError: unreadable offsets file offsets.json: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
offsets payload must be a JSON object, got {type(payload).__name__}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:78. {type(payload).__name__} is the
Python type the JSON decoded to — list, str, int.
Means. The file parsed as JSON but the top level is not an object. The format is
{"last_byte_offset": …, "seen_line_sha256": […]}; a bare array is an older or foreign format.
Status. Uncaught → exit 1.
Fix. Delete the file.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
p.write_text("[1, 2]")
try:
ProcessedOffsets.load(p)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
PY
last_byte_offset must be int, got {type(offset_raw).__name__}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:85. {type(offset_raw).__name__} is the
decoded type — str, float, NoneType, or bool (a JSON true is rejected here even though
bool is an int subclass in Python).
Means. The offset is fed to file.seek(). A string that happens to look numeric is not
coerced: "512" from a hand-edit and 512 from the writer are different values, and guessing
which one was meant risks re-reading or skipping audit lines.
Status. Uncaught → exit 1.
Fix. Delete the file.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
p.write_text('{"last_byte_offset": "512"}')
try:
ProcessedOffsets.load(p)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
PY
last_byte_offset must be non-negative, got {offset_raw}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:89. {offset_raw} is the offending
integer itself, printed with str(): -1.
Means. A negative seek target. Python's seek() would raise OSError from inside the read
loop, halfway through a scan; this catches it at load with a sentence that names the file's
problem.
Status. Uncaught → exit 1.
Fix. Delete the file.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
p.write_text('{"last_byte_offset": -1}')
try:
ProcessedOffsets.load(p)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
PY
seen_line_sha256 must be list, got {type(seen_raw).__name__}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:93. {type(seen_raw).__name__} is the
decoded type — dict, str, NoneType.
Means. The seen-hash set is persisted as a JSON array (sorted() on save) and re-read as a
list. Anything else is a foreign format. A missing key is fine — it defaults to [].
Status. Uncaught → exit 1.
Fix. Delete the file.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
p.write_text('{"seen_line_sha256": {}}')
try:
ProcessedOffsets.load(p)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
PY
seen_line_sha256 entries must be str, got {type(item).__name__}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:99. {type(item).__name__} is the type
of the first non-string element; the scan stops there, so a file with several bad entries names
only the first.
Means. Every member of the set is a hex SHA-256 digest. A number or a nested object would be compared against digests and never match, silently disabling the de-duplication that stops a restart from re-emitting handoffs.
Status. Uncaught → exit 1.
Fix. Delete the file.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
p.write_text('{"seen_line_sha256": [1]}')
try:
ProcessedOffsets.load(p)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
PY
refusing non-monotonic save: current={self.last_byte_offset} < persisted={existing.last_byte_offset}¶
OffsetsCorruptError, nautilus/forensics/offsets.py:120, from save() — the only one of the
seven that is not a load failure. {self.last_byte_offset} is the offset the worker tried to
write; {existing.last_byte_offset} is the one already on disk.
Means. The worker tried to save an offset behind the persisted one, which on the next run would re-read audit lines that have already been processed and re-emit their inferred handoffs. It is refused and the persisted value stands — the save is a no-op, not a partial write.
Deleting the file is the wrong fix here. Two workers on one offsets path is the usual cause;
run one. The legitimate regression is log rotation, and the worker already handles it:
_process_segment (handoff_worker.py:222-234) detects last_byte_offset > file size, WARNs
handoff_worker: audit file truncated or rotated …, and _persist_offsets
(handoff_worker.py:308-324) unlinks the stale file before writing the reset state.
Status. Uncaught OffsetsCorruptError out of run_worker → exit 1. Everything the segment
produced has already been emitted to the sink at this point — the save is the last step — so a
retry without fixing the cause re-emits that segment.
Fix. Ensure a single worker per offsets path. If you are deliberately rewinding, unlink the
file first, exactly as _persist_offsets does.
python - <<'PY'
import pathlib, tempfile
from nautilus.forensics.offsets import ProcessedOffsets
p = pathlib.Path(tempfile.mkdtemp()) / "offsets.json"
ProcessedOffsets(last_byte_offset=4096).save(p)
try:
ProcessedOffsets(last_byte_offset=1024).save(p)
except Exception as exc:
print(f"{type(exc).__name__}: {exc}")
print("persisted still:", ProcessedOffsets.load(p).last_byte_offset)
PY
OffsetsCorruptError: refusing non-monotonic save: current=1024 < persisted=4096
persisted still: 4096
Audit records¶
AuditRecord has no {NAUTILUS_METADATA_KEY!r} metadata¶
KeyError, nautilus/audit/logger.py:385. A record was read that carries no Nautilus
metadata block. It came from a different writer, or the log mixes sources. Check that
audit.path is not shared with another tool.
Offline verification¶
nautilus attestation verify checks a chained log without a running broker: link hashes,
per-line JWS signatures, and optionally an out-of-band head hash and checkpoint token.
Failures are reported by the command's own output rather than as exceptions; --json gives a
machine-readable verdict. --expected-head fails when the mirrored line hash is absent, which is
how tail truncation is caught — a chain that has had lines removed from the end is internally
consistent and only an external witness can detect it.