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

RequireAndVerifyClientCert — handshake fails if the client presents no cert

Chain verification

x509.Certificate.Verify — cert must chain to the configured CA pool

Validity period

Part of chain verification — cert notAfter must not be in the past

CRL check

VerifyPeerCertificate callback (orchestrator/tls.go) — cert serial must not appear in the loaded CRL

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() produces cert-orchestrator-health-revoked.stderr proving that a CRL-revoked cert is rejected (VAL02-4 scenario).

  • Phase 7 produces cert-orchestrator-health-revoked-after-reload.stderr and cert-distributed-health-revoked-after-sync.stderr proving 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 required

  • Certificate from an untrusted CA (invalid chain) → TLS chain verification failure

  • Expired client certificate (notAfter in the past) → TLS validity period failure

  • Revoked client certificate (serial in CRL) → VerifyPeerCertificate rejection

  • Wrong 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-Operator header), 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-a cert issued and rotated

  • Phase 1 (revoke): node.crt (node-a) revoked; serial written to $CERT_DIR/revoked.crl

  • Phase 4: node-b.crt issued (initially valid); revoked.crl exists

  • Phase 5: CRL-enforcing control-plane on localhost:18443 (--tls-crl-file $CERT_DIR/revoked.crl)

  • Phase 7: node-b.crt also revoked; revoked.crl updated; dynamic CRL reload confirmed

  • Phase 8: node-c.crt issued and rotated; valid 90-day cert; not in CRL

Phase 9 setup (generated inline):

  1. 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-2

  2. Rogue leaf cert (rogue-node.crt): signed by the rogue CA; CN deliberately set to node-b.edge.local (same as a legitimate node) to prove rejection is chain-based, not CN-based

  3. Expired leaf cert (expired-node.crt): signed by the real CA through an inline openssl ca micro-CA config with notBefore and notAfter forced 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:

  1. CA trust anchor (who signed the cert)

  2. CRL (whether the cert has been explicitly revoked)

  3. 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 ClientAuth

handshake: certificate required

VAL02-2

invalid_chain

rogue-node.crt (rogue CA)

x509.Certificate.Verify

chain: unknown authority

VAL02-3

expired_cert

expired-node.crt (real CA, 2020)

x509.Certificate.Verify

chain: certificate has expired

VAL02-4

revoked_cert

node.crt (node-a, in CRL)

VerifyPeerCertificate

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

cert-rejection-missing-client-cert.stdout

curl without --cert/--key

empty (no successful response)

cert-rejection-missing-client-cert.stderr

same

matches certificate required / handshake failure

cert-rejection-invalid-chain.stdout

curl with rogue-node.crt

empty

cert-rejection-invalid-chain.stderr

same

matches unknown ca / unknown authority / certificate verify failed

cert-rejection-expired-cert.stdout

curl with expired-node.crt

empty

cert-rejection-expired-cert.stderr

same

matches expired / not yet valid / certificate verify failed

cert-rejection-revoked.stdout

curl with node.crt (revoked)

empty

cert-rejection-revoked.stderr

same

matches revoked / bad certificate / certificate verify failed

cert-rejection-wrong-server-trust.stdout

curl with rogue-ca.crt as CA bundle

empty

cert-rejection-wrong-server-trust.stderr

same

matches certificate verify failed / SSL certificate problem

cert-rejection-val02-report.txt

Composite report written by Phase 9

5/5 checks PASS


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

curl exits non-zero, stdout is empty, and stderr matches the missing-cert pattern

VAL02-2

curl exits non-zero, stdout is empty, and stderr matches the invalid-chain pattern

VAL02-3

curl exits non-zero, stdout is empty, and stderr matches the expired-cert pattern

VAL02-4

curl exits non-zero, stdout is empty, stderr matches the revoked-cert pattern, and Phase 5 retained audit evidence still shows cert.revocation.rejected

VAL02-5

curl exits non-zero, stdout is empty, and stderr matches the wrong-server-trust pattern

Overall pass: cert-rejection-val02-report.txt reports 5/5 checks PASS.

Failure interpretation:

  • VAL02-1 fails: ClientAuth may not be set to RequireAndVerifyClientCert, or the control-plane may not have been reachable at all; check cert-orchestrator-tls.log and confirm the stderr contains the expected handshake text rather than a transport error

  • VAL02-2 fails: chain verification may not be enforced, or the rogue cert generation may have failed; inspect cert-rejection-invalid-chain.stderr for the expected CA error

  • VAL02-3 fails: the expired cert may not have been minted with the intended dates; inspect expired-node.crt with openssl x509 -noout -dates

  • VAL02-4 fails: the CRL file may not have been loaded or the phase reused the wrong cert; check both cert-rejection-revoked.stderr and cert-revocation-rejected-events.json

  • VAL02-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