VAL 02 — Trust-Chain Rejection Validation¶
1. Purpose¶
This validation proves that the AutonomyOps control-plane consistently rejects mTLS
client connections that fail certificate trust-chain verification across five distinct
failure categories. The Phase 9 harness grades each case on two concrete signals:
an empty response body and an expected error signature in stderr. Where a failure mode
depends on server-side revocation logic, the validation also reuses the retained Phase 5
audit evidence already captured for cert.revocation.rejected.
The control-plane uses Go’s crypto/tls with ClientAuth = RequireAndVerifyClientCert.
This configuration provides four independent rejection gates:
Gate |
What it checks |
|---|---|
Missing client cert |
|
Chain verification |
|
Validity period |
Part of chain verification — cert |
CRL check |
|
A fifth gate is validated from the client side:
Gate |
What it checks |
|---|---|
Server cert trust |
curl’s own CA verification — the server’s cert must chain to the client’s CA bundle |
VAL 02 exercises all five gates with distinct evidence files and a composite PASS/FAIL report.
2. Coverage vs. existing lab¶
Already partially covered (no VAL naming):
Phase 5 of
run_cert_lab()producescert-orchestrator-health-revoked.stderrproving that a CRL-revoked cert is rejected (VAL02-4 scenario).Phase 7 produces
cert-orchestrator-health-revoked-after-reload.stderrandcert-distributed-health-revoked-after-sync.stderrproving dynamic CRL reload without restart.
New in Phase 9 (VAL02):
Scenario |
Gap in existing lab |
|---|---|
VAL02-1: missing client cert |
Not tested |
VAL02-2: invalid chain (rogue CA) |
Not tested |
VAL02-3: expired cert |
Not tested |
VAL02-4: revoked cert |
Tested implicitly in Phase 5; VAL02 adds named evidence + composite report |
VAL02-5: wrong server trust (client side) |
Not tested |
3. Scope¶
Covered¶
Missing client certificate (no
--cert/--key) → TLS alert: certificate requiredCertificate from an untrusted CA (invalid chain) → TLS chain verification failure
Expired client certificate (
notAfterin the past) → TLS validity period failureRevoked client certificate (serial in CRL) →
VerifyPeerCertificaterejectionWrong CA bundle on client (server cert not trusted) → client-side TLS verify failure
Not covered (deferred)¶
CA rotation: not implemented in the product; no CLI support
Server-certificate rejection scenarios: the server’s own TLS cert is provisioned once at startup; server-side cert rotation requires restart and is out of scope for this validation
Partial chain attacks (intermediate CA injection): the lab uses single-depth PKI; multi-tier chains are not tested
CRL staleness / propagation window: the control-plane hot-reloads the local CRL file, but this validation does not formally time how quickly newly distributed revocations are enforced across a multi-node publisher/sync topology
CN/SAN identity matching at TLS layer: the control-plane does not enforce client CN/SAN matching at the TLS layer; identity-layer authorization uses the RBAC system (
X-Autonomy-Operatorheader), not the cert CN — see Additional findings
4. Harness¶
VAL02 is embedded in run_cert_lab() as Phase 9 within
scripts/labs/run_cli_audit_lab.sh. No separate runner is required.
Setup inherited from earlier phases:
Phase 1: local CA at
$CERT_DIR/ca.crt/ca.key;node-acert issued and rotatedPhase 1 (revoke):
node.crt(node-a) revoked; serial written to$CERT_DIR/revoked.crlPhase 4:
node-b.crtissued (initially valid);revoked.crlexistsPhase 5: CRL-enforcing control-plane on
localhost:18443(--tls-crl-file $CERT_DIR/revoked.crl)Phase 7:
node-b.crtalso revoked;revoked.crlupdated; dynamic CRL reload confirmedPhase 8:
node-c.crtissued and rotated; valid 90-day cert; not in CRL
Phase 9 setup (generated inline):
Rogue CA (
rogue-ca.crt/rogue-ca.key): a fresh self-signed CA not registered with the control-plane; used to issue a cert for VAL02-2Rogue leaf cert (
rogue-node.crt): signed by the rogue CA; CN deliberately set tonode-b.edge.local(same as a legitimate node) to prove rejection is chain-based, not CN-basedExpired leaf cert (
expired-node.crt): signed by the real CA through an inlineopenssl camicro-CA config withnotBeforeandnotAfterforced to January 2020 (well in the past)
The control-plane on :18443 is NOT restarted between Phase 8 and Phase 9. The CRL
(revoked.crl) loaded at startup already contains the serials for node-a and node-b,
making VAL02-4 reusable without any additional revoke call.
5. Exact Scenarios¶
VAL02-1 — Missing Client Certificate¶
Purpose: Prove that the control-plane requires a client certificate when mTLS is configured.
Action:
curl -fsS \
--cacert /tmp/.../ca.crt \
https://localhost:18443/v1/health
# note: no --cert / --key flags
Evidence file: autonomy/cert-rejection-missing-client-cert.stderr
Pass criterion: curl exits non-zero, stdout is empty, and stderr matches
certificate required, handshake failure, or alert certificate required.
What is proven: RequireAndVerifyClientCert is active — the control-plane does not
fall back to anonymous connections even when the client omits the certificate.
VAL02-2 — Invalid Chain (Rogue CA)¶
Purpose: Prove that a client certificate signed by an untrusted CA is rejected, even when the CN matches a known node identity.
Setup: rogue-node.crt is signed by rogue-ca.crt; CN = node-b.edge.local. The
control-plane trusts only the real CA (ca.crt).
Action:
curl -fsS \
--cacert /tmp/.../ca.crt \
--cert /tmp/.../rogue-node.crt \
--key /tmp/.../rogue-node.key \
https://localhost:18443/v1/health
Evidence file: autonomy/cert-rejection-invalid-chain.stderr
Pass criterion: curl exits non-zero, stdout is empty, and stderr matches
unknown ca, unknown authority, certificate verify failed, or
alert unknown ca. The VerifyPeerCertificate CRL callback is not expected to run
because standard chain verification aborts first.
What is proven: Identity-level access is chain-anchored, not CN-anchored. A rogue CA cannot impersonate a legitimate node by forging the CN.
VAL02-3 — Expired Certificate¶
Purpose: Prove that a certificate whose validity period has passed is rejected.
Setup: expired-node.crt is signed by the real CA with notBefore=2020-01-01 and
notAfter=2020-01-02 (both in the past).
Action:
curl -fsS \
--cacert /tmp/.../ca.crt \
--cert /tmp/.../expired-node.crt \
--key /tmp/.../expired-node.key \
https://localhost:18443/v1/health
Evidence file: autonomy/cert-rejection-expired-cert.stderr
Pass criterion: curl exits non-zero, stdout is empty, and stderr matches
expired, not yet valid, certificate verify failed, or
alert certificate expired.
What is proven: Certificate validity periods are enforced. An operator cannot reuse an expired cert for node access, even if the cert is otherwise valid (correct CA, not revoked).
VAL02-4 — Revoked Certificate¶
Purpose: Formally name and report the CRL enforcement proof (which is also implicitly covered by Phase 5 evidence) within the VAL02 composite report.
Setup: node.crt (CN=node-a.edge.local) was revoked in Phase 1; its serial is in
revoked.crl, which was loaded by the control-plane at startup. The same CRL still
applies.
Action:
curl -fsS \
--cacert /tmp/.../ca.crt \
--cert /tmp/.../node.crt \
--key /tmp/.../node.key \
https://localhost:18443/v1/health
Evidence file: autonomy/cert-rejection-revoked.stderr
Pass criterion: curl exits non-zero, stdout is empty, and stderr matches
revoked, certificate verify failed, bad certificate, or
alert bad certificate.
Relationship to Phase 5: This is the same scenario as
cert-orchestrator-health-revoked.stderr, re-run here with VAL naming so the composite
report covers all five rejection gates uniformly. The callback-specific audit proof still
comes from the existing cert-revocation-rejected-events.json evidence captured earlier
in the cert lab.
What is proven: The CRL enforcement path (VerifyPeerCertificate callback) actively
rejects connections from nodes whose certificates have been revoked via autonomy cert revoke.
VAL02-5 — Wrong Server Trust (Bidirectional mTLS)¶
Purpose: Prove that the mTLS setup is bidirectional — the client cannot connect to the server if it cannot verify the server’s certificate chain.
Setup: rogue-ca.crt is provided as the CA bundle to curl. The server presents a
cert signed by the real CA. The rogue CA does not trust the real CA’s signature.
Action:
curl -fsS \
--cacert /tmp/.../rogue-ca.crt \
--cert /tmp/.../node-c.crt \
--key /tmp/.../node-c.key \
https://localhost:18443/v1/health
Evidence file: autonomy/cert-rejection-wrong-server-trust.stderr
Pass criterion: curl exits non-zero, stdout is empty, and stderr matches
certificate verify failed, SSL certificate problem, self-signed certificate,
unknown ca, or unknown authority.
What is proven: The server cannot be impersonated by a rogue server with a different CA. mTLS is bidirectional: the client verifies the server’s cert and the server verifies the client’s cert. A valid client cert alone is not sufficient to connect to the real server if the client does not have the correct CA bundle.
6. Additional Findings¶
right_ca_wrong_cn (expected behavior — not a rejection)¶
Scenario: A client presents a certificate signed by the trusted CA but with a CN not
corresponding to any registered node identity (e.g., CN=rogue.internal).
Behavior: The TLS handshake succeeds if:
The cert chains to the trusted CA
The cert is not expired
The cert serial is not in the CRL
Why this is expected: The AutonomyOps control-plane does not enforce client CN/SAN
matching at the TLS layer. TLS trust-chain validation only verifies the CA relationship
and revocation status. Application-layer identity authorization (which operator is
performing the action) is handled separately by the RBAC system via the
X-Autonomy-Operator header, not by the cert CN.
Implication for operators: The CN in a client certificate is an organizational label, not an access control mechanism. Access control is provided by:
CA trust anchor (who signed the cert)
CRL (whether the cert has been explicitly revoked)
RBAC role assignments (what actions the operator identity is permitted to perform)
This is documented in the scope boundary; no later committed VAL slice is planned to test CN-based rejection, because CN-based rejection is not a product behavior.
7. Scenario Matrix¶
ID |
Name |
Test cert |
Rejection layer |
Error class |
|---|---|---|---|---|
VAL02-1 |
missing_client_cert |
(none) |
TLS |
handshake: certificate required |
VAL02-2 |
invalid_chain |
rogue-node.crt (rogue CA) |
|
chain: unknown authority |
VAL02-3 |
expired_cert |
expired-node.crt (real CA, 2020) |
|
chain: certificate has expired |
VAL02-4 |
revoked_cert |
node.crt (node-a, in CRL) |
|
CRL: cert.revocation.rejected |
VAL02-5 |
wrong_server_trust |
node-c.crt (valid) + wrong CA bundle |
client-side TLS verify |
client: certificate verify failed |
Note: VAL02-2 uses CN=node-b.edge.local (same as a legitimate node) intentionally — the
rejection is CA-chain-based, not CN-based, confirming VAL02-2 is a stronger proof than a
random CN would provide.
8. Evidence Files¶
All files are written to $EVIDENCE_DIR/autonomy/ by the lab runner.
File |
Produced by |
Pass criterion |
|---|---|---|
|
curl without |
empty (no successful response) |
|
same |
matches |
|
curl with rogue-node.crt |
empty |
|
same |
matches |
|
curl with expired-node.crt |
empty |
|
same |
matches |
|
curl with node.crt (revoked) |
empty |
|
same |
matches |
|
curl with rogue-ca.crt as CA bundle |
empty |
|
same |
matches |
|
Composite report written by Phase 9 |
|
9. Logs and Metrics Expected¶
The control-plane server log (cert-orchestrator-tls.log) may contain TLS-layer rejection
entries for each failed handshake, depending on Go’s TLS log verbosity. The primary
observable evidence for Phase 9 is the combination of empty stdout plus expected stderr
signatures, not the server log, because Go’s crypto/tls does not expose structured
per-connection rejection events at INFO level for every failure class.
The cert.revocation.rejected slog event for VAL02-4 is produced by the server’s
VerifyPeerCertificate callback in orchestrator/tls.go. The existing
cert-revocation-rejected-events.json audit query from Phase 5 already captures this
callback-specific event. Phase 9 does not issue a separate audit query for VAL02-4; the
Phase 5 evidence remains the authoritative audit proof for that rejection layer.
10. Pass/Fail Criteria¶
Check ID |
Pass condition |
|---|---|
VAL02-1 |
|
VAL02-2 |
|
VAL02-3 |
|
VAL02-4 |
|
VAL02-5 |
|
Overall pass: cert-rejection-val02-report.txt reports 5/5 checks PASS.
Failure interpretation:
VAL02-1 fails:
ClientAuthmay not be set toRequireAndVerifyClientCert, or the control-plane may not have been reachable at all; checkcert-orchestrator-tls.logand confirm the stderr contains the expected handshake text rather than a transport errorVAL02-2 fails: chain verification may not be enforced, or the rogue cert generation may have failed; inspect
cert-rejection-invalid-chain.stderrfor the expected CA errorVAL02-3 fails: the expired cert may not have been minted with the intended dates; inspect
expired-node.crtwithopenssl x509 -noout -datesVAL02-4 fails: the CRL file may not have been loaded or the phase reused the wrong cert; check both
cert-rejection-revoked.stderrandcert-revocation-rejected-events.jsonVAL02-5 fails: curl may have used the wrong CA bundle or the wrong-server-trust stderr pattern changed; inspect the exact TLS verify output
11. Report Template¶
The composite report written to autonomy/cert-rejection-val02-report.txt:
# VAL 02 — Trust-Chain Rejection Validation Report
timestamp: 2026-03-20T10:00:00Z
## Results
VAL02-1 missing_client_cert: PASS
VAL02-2 invalid_chain: PASS
VAL02-3 expired_cert: PASS
VAL02-4 revoked_cert: PASS
VAL02-5 wrong_server_trust: PASS
## Note: right_ca_wrong_cn (expected accepted)
A cert from the trusted CA with an unexpected CN is accepted at the TLS
layer; identity-layer authorization is RBAC-based (X-Autonomy-Operator),
not cert CN-based. This is expected behavior, not a gap.
A run is green when all five lines end in PASS. The runner prints
VAL 02: 5/5 checks PASS (report: cert-rejection-val02-report.txt) to stdout.
12. How to Run¶
Phase 9 executes automatically as part of run_cert_lab() when the full lab is run:
export GOROOT=/home/ubuntu/.local/go1.25.7
export PATH="$GOROOT/bin:$PATH"
export GOTOOLCHAIN=local
bash scripts/labs/run_cli_audit_lab.sh
To inspect results after a run:
# Composite report
cat evidence/pr17-cli-audit-local-2026-03-17/autonomy/cert-rejection-val02-report.txt
# Inspect individual rejection stderr
for f in missing-client-cert invalid-chain expired-cert revoked wrong-server-trust; do
echo "=== $f ==="; \
cat "evidence/pr17-cli-audit-local-2026-03-17/autonomy/cert-rejection-${f}.stderr"; \
done
# Confirm server audit event for VAL02-4 (revoked cert)
jq '.[0] | {event, resource, outcome}' \
evidence/pr17-cli-audit-local-2026-03-17/autonomy/cert-revocation-rejected-events.json