Attestations¶
After apply, update, rollback, restore, config and
installation remove — on failure as well as success — the manager writes a
signed record of what it did:
/var/lib/<product>/attestations/op_01K2Z9X7QK8V3H4M5N6P7R8S9T.json
/var/lib/<product>/attestations/op_01K2Z9X7QK8V3H4M5N6P7R8S9T.json.minisig
The name is the operation id, so a statement lines up with the journal entry for the same run, and the ids sort in the order the operations happened.
The manager already verifies signatures, pins digests, refuses unverified images and journals every step. Without this, that evidence stays on the machine in a format nothing else reads, and an operator asked to demonstrate that production runs what the vendor shipped reconstructs it by hand.
The document is an in-toto Statement. The signature is minisign's, detached, so verifying one is a command you already know:
$ cd /var/lib/demo/attestations
$ minisign -Vm op_01K2Z9X7QK8V3H4M5N6P7R8S9T.json -P RWQf6L...
Signature and comment signature verified
Trusted comment: morzer demo apply op_01K2Z9X7QK8V3H4M5N6P7R8S9T
The key is this installation's own — see
the security policy
for what it is and what losing it costs. morzer status --json reports it as
signing.public_key, and every statement carries it too, so a reader holding
only the file can check it.
What a signature proves¶
Every statement carries this sentence in a bound field, and it is in the
document rather than only in this page because the reader who most needs it is
the one who was handed the file without it:
The bound on the claim
This signature proves that a process holding this installation's signing key produced these bytes. It does not prove the bytes are true, it does not prove the machine was uncompromised when it signed, and it does not identify the operator.
A statement is what the manager observed and recorded. An attacker who owns the machine owns the key, and their statements verify too.
What is in one¶
| Field | |
|---|---|
subject |
The release the operation was about: name, version, and the content digest. |
predicate.bound |
The sentence above, verbatim. |
predicate.operation |
Id, kind, start, end, and outcome. |
predicate.installation |
Id, product, mode, manager version, the signing key, and any predecessor keys. |
predicate.release |
Versions moved between, the source scheme, the content digest. |
predicate.verification |
What was checked before the manager acted — see below. |
predicate.images |
Each image by reference and digest, and whether it came from a registry or the bundle. |
predicate.config |
Parameter names, and a digest of the rendered configuration. |
predicate.steps |
Each step, its outcome, and how long it took. |
Configuration: names always, values never¶
Parameter values do not appear. They include ports and hostnames, and this is an artifact deliberately readable by somebody outside the organisation.
config.rendered_digest is what makes drift detectable without publishing what
drifted, and it is salted per installation. Two consequences, both
deliberate:
- It detects change on one machine over time, which is the audit question.
- It is not comparable across machines, which is a capability given up on purpose: the input is a handful of ports and booleans, and an unsalted digest over that space can be brute-forced back to its inputs by anyone holding the document.
A machine with no salt — one that reached schema 6 by upgrade and has not run
init since — emits no digest rather than an unsalted one.
Which operation verified the signature¶
Signatures are checked when a release is staged: by init for the release it
installs, and by update for the one it installs. Every other operation runs
against a release already on disk and verifies nothing. So
predicate.verification carries two different claims, and never both:
| Field | Claim |
|---|---|
signature_verified, key_id |
This operation verified the bundle's signature, against the key with this id. Only an update makes it. |
signature_verified_when_staged |
An earlier operation verified it when it staged this release: key_id, the operation_id that verified it, and at. An apply, rollback, config, backup, task or remove statement carries it. |
Neither field is present when no signature was verified: an unsigned release, a
machine with no signing keys, or a release staged by a version of morzer that
did not record the check. A release returned to with rollback --to is the
fourth case, unless it is the previous release: the release store keeps no
record of how a release was verified, and the previous release is the one
other than the current that the manager keeps a record for. Absent means no claim. Neither field is ever false,
which would read as "checked, and it failed" — a much stronger claim, and one
an auditor would act on.
key_id is the minisign key id, as minisign prints it, so it can be matched to
policy.signing_keys by eye. when_staged is the manager's own record of a past
check, written on the machine that signs this statement: it says what that
machine recorded, not that the bundle on disk still matches.
Steps carry durations, not timestamps¶
predicate.steps[].duration_ms is what the operation journal records. A
duration plus the operation's own started places a step in time; widening a
persisted record to feed a new artifact would be the tail wagging the dog.
Validating one¶
A JSON Schema ships with every release, generated from the Go types that produce the document rather than written alongside them:
schemas/morzer-attestation-v2.json
The predicate type is https://morzecrew.github.io/morzer/attestation/v2. v2
adds signature_verified_when_staged and nothing else. Statements written
before it say …/attestation/v1, validate against
schemas/morzer-attestation-v1.json, which stays published unchanged, and are
still read by attest verify.
It pins _type and predicateType to single values, so a document from another
contract does not validate against it — notably a SLSA provenance document,
which describes how an artifact was built where this describes how one was
deployed. Reusing SLSA's predicate would have produced a document that
validates against a well-known schema while asserting something that schema does
not mean.
Regenerated by just schemas, and a test fails the build when the checked-in
copy no longer matches the types.
Reading them back¶
$ morzer attest verify
4 statement(s), 0 problem(s)
✔ op_01K2Z9X7QK8V3H4M5N6P7R8S9T.json
apply succeeded
...
Three questions, answered separately because an operator acts on them differently.
Signature. Did a key this installation knows about produce these bytes? Four outcomes, and the third is the one worth understanding:
| Outcome | |
|---|---|
| signed by the current key | The machine's key today produced it. |
| signed by a predecessor | A key this installation has since retired produced it. Provenance, not validity — see below. |
| unverifiable | No key this installation accounts for produced it. This is a finding. |
| unsigned | There is no signature. The machine had no key when it emitted this, which is ordinary for an installation upgraded into schema 6. |
A predecessor match is reported as passing, because a rebuilt or rotated machine is a normal history rather than a fault. What it establishes is that the bytes came from an earlier incarnation of this installation — not that the signature was made while that key was in service. The only date available comes from the artifact, and the artifact is what a forger writes.
Folding that outcome into plain validity is what would make rotation useless: a rotation after a suspected compromise would still accept whatever the old key signs, forever, which is the one case rotation exists for.
Chain. Do the statements that moved between releases join up? Each one's
from_version should be where the previous operation left the installation. A
gap means a release was installed by something that filed no record — which is
what an audit is trying to rule out. An apply moves nothing and carries no
from_version, so it does not participate.
Live. --against-live compares the newest successful statement against
what the runtime reports right now:
$ morzer attest verify --against-live
5 statement(s), 1 problem(s)
✖ live: image
app runs registry.example/demo/app at sha256:9f2c0a1b4d5e, which no attested image matches
Four ways it can disagree, and each is something somebody does by hand:
- an image running at a digest the statement never mentioned;
- an attested image that is no longer running anywhere;
- an image running that the statement never attested at all;
- the rendered configuration on disk no longer digesting to what was attested.
An image that only a one-shot migrate service runs is not expected to be
running, and is not reported missing: the service exits by design once its
migrations are done. That holds only while nothing the deployment starts runs
the same digest. A migration that runs the application's own image does not
hide the application's disappearance.
The configuration check reports that it differs and never how. The digest is salted (above), so there is nothing in the statement to diff against — which is the trade that keeps a port number out of a document that travels.
Images are compared as a set, not per service
predicate.images records what the release said should run, by repository
and digest. It does not record which service runs which, because the
manifest does not say: it declares images by name, and the Compose file
decides where each one is used — possibly in several services.
So the comparison asks "is every attested digest running, and is everything running attested", and two situations slip through it:
- Two services swap images with each other. Both digests are still running, so nothing is reported, even though both services run the wrong thing.
- Two services run the same digest and one stops. The other keeps that digest present, so the stopped one is not reported missing.
Everything else is caught: a digest nobody attested, a digest attested and running nowhere, and any change to the rendered configuration. Closing the gap means recording the service each image was deployed to, which is a change to the predicate — see RFC 0025 §12.
Newest successful, deliberately: comparing against a failed operation would report every difference that failure caused as though a person had made it.
attest verify exits non-zero when it finds a problem, so it works in a cron
job or a CI step without parsing anything. A predecessor signature is not a
problem; an unverifiable one, a broken chain and a live mismatch all are.
attest log — what happened, without asking whether to believe it¶
$ morzer attest log
2 statement(s), newest first
OPERATION KIND OUTCOME RELEASE SIGNATURE
op_01K2ZB4M8QF0R7V3X5Y6Z7A8Q9 config succeeded 1.3.0 signed
op_01K2Z9X7QK8V3H4M5N6P7R8S9T update succeeded 1.2.0 -> 1.3.0 signed
Deliberately not verify with less output. signed says a signature is
there, never that it checks out — an operator reading a timeline during an
incident should not have it withheld because a key is missing, and one asking
whether a record can be trusted should not be answered by a listing.
Getting them off the machine¶
Statements are pushed as they are written, to the same targets this installation keeps its backups on:
<target>/attestations/op_01K2Z9X7QK8V3H4M5N6P7R8S9T.json
<target>/attestations/op_01K2Z9X7QK8V3H4M5N6P7R8S9T.json.minisig
They are not backups and never appear as one: backup list reports a directory
only when it holds a backup.json, so nothing here can be restored from or
counted by retention.
A failed push does not fail the operation — the opposite of a backup
morzer backup fails when a push fails, because reporting success for data
that is only on the doomed disk is exactly what pushing exists to prevent.
This inverts that on purpose. An attestation that did not leave is a gap in
a record whose subject — the deployment — is fine, and whose local copy is
still here. Failing an update because a log shipper was down would stop
the security fix an operator is applying, for bookkeeping.
So the gap is reported instead: a warning at the time, doctor until it is
closed, and morzer attest push to close it.
$ morzer attest push
pushed 3 attestation(s) to 1 target
$ morzer attest push
every one of 3 attestation(s) is already on 1 target
It sends only what is not already there, so it is safe from cron — and a statement counts as there only when the target holds its signature too, because the document goes first and a transfer that died between the two would otherwise look complete forever.
doctor reports the same gap under backup.attestations-pushed:
! attestations have left this machine
1 of 1 attestation(s) are only on this machine
Both count statements, however many targets there are. With more than one,
doctor says how many statements are missing from at least one of them, since
a statement can be on one target and not another.
An installation with no backup target has nowhere to push, so what it
writes stays on the machine and is lost with it; a target removed earlier keeps
what it was sent. doctor says how many statements are here, as information
rather than a warning, which doctor --verbose, plain output and --json
show:
$ morzer doctor --verbose
…
✓ attestations: no backup target to push them to
4 attestation(s) on this machine, and no backup target is configured to push them to
Not a warning, because nothing records whether a machine is air-gapped on
purpose, and a warning on every machine that chose to keep its record local
would teach its operator to read past doctor. The default view folds it into
the category's tick, like every check that passed. Configuring a
backup target gives the record somewhere to go, and
morzer attest push then sends what is already here.
What this does not do yet¶
Stated here rather than discovered:
- Nothing prunes them. One statement per operation is small, but
configandapplyare frequent — a machine applying at every boot accumulates one per boot. The directory has no retention policy the way releases and backups do. verifyreads the local directory, or a path you give it. It will not fetch a target's copies to check them, so verifying what is off-site means fetching them yourself first.- Attestations go where the backups go. There is no separate target list, so an installation that wants its records somewhere its data is not cannot say so yet.
A rebuilt machine signs with a new key, and records its predecessor, as does
one whose key was replaced with
installation rotate-signing-key.
A signature that checks out against a predecessor is provenance — "signed by
an earlier key of this installation" — and never plain validity.