Make a recovery requirement executable
Evaluate successful and failed rehearsals against the same signed recovery policy.
Follow along
Goal: evaluate both recovery results against a signed policy, including the failed rehearsal. The command's success condition belongs in the policy; signing a result alone does not make it acceptable.
The commands below were run end to end, offline, with CI/lock 4.6.2. They use a local file key for the producer and the policy signer so that every result can be checked without a platform. The last section covers the differences when your policy is signed by a platform identity.
Before you start
Complete recording recovery evidence. You need its rehearsal script, which writes out/restore-report.json on every run, and the policy schema for reference. You also need jq and OpenSSL 3. The LibreSSL openssl that ships with macOS cannot generate ed25519 keys, so install OpenSSL 3 and put it first on your PATH.
What the policy binds to
A verification names a subject: the thing the evidence must be about. Choose the report the rehearsal wrote, not the commit it ran from.
| Subject | What it identifies | What it cannot tell apart |
|---|---|---|
The report's SHA-256 (-f gap-report.json) | The exact bytes a rehearsal run produced | Two runs that wrote byte-identical reports. Put a run identifier or timestamp in the report so each run's digest is its own. |
The commit (--subjects sha1:<commit>) | Git context. Every run at that commit carries it. | A passing run and a failing run at the same commit. Offered both records, cilock verify passes on the passing one. |
The commit is still recorded by the git attestor and is worth reading. It says what source the rehearsal ran from. It does not say which rehearsal result you are looking at.
-f matches the digest against every file the step recorded, inputs as well as outputs. A report copy kept in the working directory is an input of every later run, and its bytes can then satisfy the binding for the wrong record. So keep the copies outside the rehearsal repository, and delete out/ before each run so that each report is new output, as below.
1. Create the keys
Keep the keys, records and report copies in a directory outside the rehearsal repository. Run this from the repository:
mkdir -p ../recovery-evidence
openssl genpkey -algorithm ed25519 -out ../recovery-evidence/producer.pem
openssl pkey -in ../recovery-evidence/producer.pem -pubout \
-out ../recovery-evidence/producer.pub.pem
openssl genpkey -algorithm ed25519 -out ../recovery-evidence/policy-signer.pem
openssl pkey -in ../recovery-evidence/policy-signer.pem -pubout \
-out ../recovery-evidence/policy-signer.pub.pem
Expected: two key pairs. producer.pem signs rehearsal evidence. policy-signer.pem signs the policy and is the only key a verifier needs. In a real deployment these belong to different people, and neither file is committed.
2. Record both rehearsals with the producer key
In the rehearsal repository:
rm -rf out
cilock run --step recovery-rehearsal -k ../recovery-evidence/producer.pem \
--platform-url "" -o ../recovery-evidence/rehearsal.json \
-- ./scripts/restore-rehearsal.sh
cp out/restore-report.json ../recovery-evidence/rehearsal-report.json
rm -rf out
REHEARSAL_LATE_WRITES=37 cilock run --step recovery-rehearsal \
-k ../recovery-evidence/producer.pem --platform-url "" \
--ignore-command-exit-code -o ../recovery-evidence/gap.json \
-- ./scripts/restore-rehearsal.sh
cp out/restore-report.json ../recovery-evidence/gap-report.json
Expected: two signed records and two copies of the report in ../recovery-evidence. Each run starts without a report, so the report it writes is new output, and the copies outside the repository are never an input of a later run. --platform-url "" keeps signing on the file key and uploads nothing. Run the remaining steps in that directory:
cd ../recovery-evidence
3. Write the policy
The rule denies any rehearsal whose command exited nonzero:
cat > recovery.rego <<'EOF'
package commandrun
import rego.v1
deny contains msg if {
input.exitcode != 0
msg := "the restore did not recover every row"
}
EOF
It trusts your rehearsal to detect missing data; it does not query the database itself. The policy attaches the rule to the command-run attestation and names the producer key as the only accepted signer of evidence:
KEYID=$(openssl dgst -sha256 -r producer.pub.pem | cut -d' ' -f1)
jq -n --arg id "$KEYID" \
--arg key "$(base64 < producer.pub.pem | tr -d '\n')" \
--arg rule "$(base64 < recovery.rego | tr -d '\n')" '{
expires: "2030-12-31T00:00:00Z",
steps: {"recovery-rehearsal": {
name: "recovery-rehearsal",
attestations: [
{type: "https://aflock.ai/attestations/material/v0.3", regopolicies: []},
{type: "https://aflock.ai/attestations/command-run/v0.2",
regopolicies: [{name: "restore recovered every row", module: $rule}]},
{type: "https://aflock.ai/attestations/product/v0.3", regopolicies: []}
],
functionaries: [{type: "publickey", publickeyid: $id}]
}},
publickeys: {($id): {keyid: $id, key: $key}}
}' > recovery.json
Expected: recovery.json with one step, one rule and one functionary. Read it before signing. Set expires to the end of the period your team will accept this policy.
4. Sign the policy
cilock sign -f recovery.json -o recovery.signed.json \
-t https://aflock.ai/policy/v0.1 -k policy-signer.pem --platform-url ""
Expected: a signed policy envelope. This does not publish the policy or activate it at a Pushgate repository. Those are separate operations with separate authorization.
5. Verify the successful rehearsal
cilock verify --offline -p recovery.signed.json -k policy-signer.pub.pem \
-a rehearsal.json -f rehearsal-report.json --format json
Expected: exit status 0 and a verdict naming the report:
"passed": true,
"verdict": "passed",
"step": "recovery-rehearsal",
"matchedSubject": "sha256:…",
"observedSubjectName": "out/restore-report.json",
-f computes the SHA-256 of rehearsal-report.json. The record passes only because it lists those exact bytes as a product of the recovery-rehearsal step.
6. Verify the failed rehearsal
cilock verify --offline -p recovery.signed.json -k policy-signer.pub.pem \
-a gap.json -f gap-report.json --format json
Expected: exit status 1, and stderr names the rule:
collection rejected: "recovery-rehearsal", Reason: "collection validation failed:
- rego policy evaluation failed for attestor type https://aflock.ai/attestations/command-run/v0.2:
policy was denied due to: the restore did not recover every row"
This is the only result that demonstrates the recovery rule. Keep stderr visible.
7. Tell the rule's denial from every other failure
Every failure below exits 1, and most print "verdict": "denied" on stdout. The JSON verdict does not say why. Read the reason on stderr before you conclude the recovery rule fired.
| What went wrong | Example | The reason on stderr |
|---|---|---|
| The recovery rule denied the run | Step 6 | rego policy evaluation failed … the restore did not recover every row |
| Wrong subject: the report is not this record's product | -a rehearsal.json -f gap-report.json | supplied artifact digest(s) [sha256:…] not present in any subject of step "recovery-rehearsal" collection |
| Wrong producer: the record is signed by a key the policy does not name | A record made with another key | collection rejected … no valid signatures for the provided verifiers found for keyids |
| No policy key, embedded trust off | Omit -k, add --no-embedded-trust | must supply a public key, CA certificates, a verifier, or a cilock built with embedded policy trust (no verdict is printed) |
| No policy key, or the wrong one | Omit -k, or pass -k producer.pub.pem | failed to verify policy signature: could not verify policy |
| The policy has expired | Sign a copy with a past expires | failed to verify policy: policy expired on … |
The wrong-producer row holds even when the report bytes are identical. Binding to the report says which result is being judged. The functionary says who may vouch for it.
Using your platform's signer
When an authorized person signs the policy through your platform, two things change:
- The evidence functionary. An enrolled agent or CI job signs with a certificate, not a file key. The functionary must constrain that certificate's identity; see the policy schema.
- The policy trust. Replace
-k policy-signer.pub.pemwith the trust material and expected signer your administrator supplies through a trusted channel. Do not derive the expected identity from the evidence you are verifying. Replace both example values below with the ones your team authorized; do not assume every deployment uses this issuer path.
[email protected]
POLICY_ISSUER=https://platform.example/fulcio/oidc
cilock verify --offline -p recovery.signed.json \
--policy-ca-roots platform-trust.pem \
--policy-timestamp-servers platform-trust.pem \
--policy-emails "$POLICY_SIGNER_EMAIL" \
--policy-fulcio-oidc-issuer "$POLICY_ISSUER" \
-a gap.json -f gap-report.json --format json
cilock policy draft --hydrate-local can fill a hand-written policy with your platform's roots before signing. Inspect the hydrated file before signing; do not blindly accept newly discovered roots. Keep -f and the report: the subject binding does not change with the signer.
Troubleshooting and next steps
If both results pass, read the signed policy and confirm the rule is attached to the command-run attestation. If both fail, find the reason in the table above before anything else. Keep the signed policy, both records and both reports for review. Offline verification of these files does not establish that every appliance feature works without network access.