Backend host_subject enforcement¶
Prompt¶
Before responding to questions or discussion points in this
document, explore the kerbside codebase thoroughly. Read
relevant source files, understand existing patterns (the
Rust SPICE proxy in rust/kerbside-proxy/ and the gRPC
control contract in kerbside/rpc/, the source driver
abstraction in kerbside/sources/, the SQLAlchemy/alembic
data model in kerbside/db.py and alembic/, audit
logging, and the .vv file generation path in
kerbside/api.py). Ground your answers in what the code
actually does today. Do not speculate about the codebase
when you could read it instead. Flag any uncertainty
explicitly rather than guessing.
This plan crosses repository boundaries. Phase 1 lands in
shakenfist/ryll (the shakenfist-spice-protocol crate);
phase 2 lands in this repo. Sub-agents executing a
cross-repo phase must be briefed that the plan they are
following lives in shakenfist/kerbside/docs/plans/ even
when all of their commits land elsewhere. Key references:
shakenfist/ryllat/home/tars/src/shakenfist/ryll— the Rust SPICE client and home ofshakenfist-spice-protocol. The verifier to change isSpiceCaVerifierinshakenfist-spice-protocol/src/client.rs.- Reference checkouts at
/srv/src-reference/spice/— the canonical subject-verification semantics are inspice-common/common/ssl_verify.c(subject_to_x509_name,verify_subject), wired up byspice-gtk/src/spice-channel.cand fed from.vvfiles byvirt-viewer/src/virt-viewer-session-spice.c. Use these to verify matching semantics rather than guessing.
When we get to detailed planning, I prefer a separate plan
file per detailed phase. These separate files should be
named for the master plan, in the same directory as the
master plan, and simply have -phase-NN-descriptive
appended before the .md file extension. Tracking of
these sub-phases should be done via a table in this master
plan under the Execution section.
I prefer one commit per logical change, and at minimum one commit per phase. Do not batch unrelated changes into a single commit. Each commit should be self-contained: it should build, pass tests, and have a clear commit message explaining what changed and why.
Situation¶
The Python SPICE proxy verified the hypervisor's TLS
certificate subject against the console's host_subject
before relaying traffic. The Rust proxy that replaced it
(PLAN-rust-proxy, complete) does not: the ryll
SpiceClient it reuses validates the certificate chain
against the source's CA but deliberately relaxes
hostname/subject matching, because SPICE server
certificates commonly lack SAN extensions. This was
accepted as a security-parity regression for the phase-3
skeleton, recorded in PLAN-rust-proxy.md's future work as
"restore before production". This plan restores it.
Exploration findings that shape this plan (verified 2026-07-17 against the current trees):
- The historical Python behaviour (commit
bb68b555,kerbside/spiceprotocol/__init__.py:147-171): optional — checked only when the console had ahost_subject— and crude. It rebuilt a subject string fromssl.getpeercert(), mapping onlycountryName,organizationName, andcommonNametoC/O/CN(anything else becameUNKNOWN(...)), joined with,, and did a single exact, case- and order-sensitive string compare. On mismatch it raisedHostSubjectInvalid, refusing the hypervisor connection with an audit event. There were no unit tests for it. - The plumbing already exists end to end and is
inert. The proto carries it
(
kerbside/rpc/kerbside.proto:100,Target .host_subjectfield 7); the servicer fills it from the console (kerbside/rpc/servicer.py:133, empty string when NULL); the proxy maps empty→Noneand passes it into ryll'sConnectionConfig.host_subject(rust/kerbside-proxy/src/backend.rs:197-207, with aTODO(host_subject)comment); and ryll's config field is documented "informational only — subject matching is not enforced" (shakenfist-spice-protocol/src/lib.rs:93). Ryll's own.vv/INI parsing also already readshost-subjectinto its config (ryll/src/config.rs:217,243,369). No public API signature needs to change anywhere. - The enforcement gap is one function.
SpiceCaVerifier::verify_server_cert(shakenfist-spice-protocol/src/client.rs:47-78) delegates chain/expiry/signature checks to rustls'sWebPkiServerVerifier, then swallows exactly theNotValidForNameerror class. It never inspects the subject. The custom verifier is only installed when a custom CA is configured (client.rs:161-185); without one, stock rustls verification (including hostname checks) applies. - Canonical matching semantics live in spice-common's
ssl_verify.c, shared by spice-gtk and fed verbatim from.vvhost-subjectfields by virt-viewer: - Parse (
subject_to_x509_name,ssl_verify.c:282-372): comma-separatedkey=valueentries; backslash may escape only\and,(anything else is a hard error); leading spaces before a key are skipped; an empty value is an error; keys are OpenSSL short names (CN,O,C, ...); entry order is preserved. - Compare (
verify_subject,ssl_verify.c:374-425): the peer certificate's subject must have exactly the same number of RDN entries, thenX509_NAME_cmpdecides — order-sensitive, per-RDN, with OpenSSL's canonical form (case-insensitive, whitespace-folded values for directory-string types). - Providing the subject string is what switches
verification on (
spice-session.c:788-795setsSPICE_SESSION_VERIFY_SUBJECT); subject verification replaces hostname verification, it does not add to it. - Who supplies real values: only the oVirt source
scraper populates
host_subjectin production, from the oVirt API'shost.certificate.subject(kerbside/sources/ovirt.py:99-116). The static source accepts it as an optional YAML key; Shaken Fist always sendsNone. The DB column is nullable (kerbside/db.py:179). - There is no test coverage anywhere. The direct-qemu
CI lane runs the backend qemu leg in plaintext — no
hypervisor TLS certificate exists in any harness
(
tools/direct-qemu/generate-tls.shgenerates only the proxy's client-facing certs). The Rust unit tests only cover the empty-string→Noneconfig mapping (backend.rs:356-369). Ryll's crate has no TLS verifier tests at all (client.rshas no test module), though it has good precedent: in-memory duplex-stream handshake tests inlink.rs, cargo-fuzz targets undershakenfist-spice-protocol/fuzz/, andrcgenas the ecosystem's dev-dependency for minting test certs (already used by ryll's --web tests and kerbside'sload_acceptortests). - A documentation bug:
docs/proxy-architecture.md:57-60already claims the backend leg "verifies the server against the expectedhost_subject". That is not true today; this plan makes it true. - Crate release mechanics: kerbside consumes
shakenfist-spice-protocolas a git dependency pinned to a ryll commit (rust/kerbside-proxy/Cargo.toml,rev = "1c6f19f3..."). There is no crates.io publish loop; "release" means landing on ryll develop and bumping therevpin in kerbside. - A naming collision to keep clear of: the proxy's
--host_subjectCLI flag (rust/kerbside-proxy/src/main.rs:101-102) andconfig.PROXY_HOST_SUBJECTare the proxy's own client-facing certificate subject, placed into.vvfiles for the client→proxy leg. This plan is about the proxy→hypervisor leg only.
Mission and problem statement¶
Restore hypervisor certificate subject pinning to the
proxy's backend leg, at security parity with (and better
fidelity than) the deleted Python implementation: when a
console carries a host_subject, the proxy must refuse to
relay to any backend whose end-entity certificate subject
does not match it, and the refusal must be observable in
audit events. When no host_subject is set, behaviour is
unchanged. The enforcement lands in the ryll crate's
verifier so that ryll (whose .vv parsing already carries
host-subject values it silently ignores) gains the same
protection for free.
Design decisions (settled)¶
- Matching semantics follow spice-common, not the old
Python code. The expected-subject string is parsed
and compared per
ssl_verify.c: comma-separatedkey=valuewith backslash-escaping of\and,only, leading-space skip before keys, empty values rejected; comparison requires the same RDN count, the same attribute types in the same order, and case-insensitive, whitespace-folded value comparison. Rationale: interoperability — the same strings operators already use in.vvfiles and that oVirt's API supplies then behave identically in virt-viewer, ryll, and the kerbside proxy. The Python implementation was an approximation that broke on any RDN outside C/O/CN. - Enforcement lives in ryll's
SpiceCaVerifier, keyed offConnectionConfig.host_subject.Nonepreserves today's behaviour exactly.Some(subject)enables the check: after chain verification, parse the end-entity certificate's subject DN and compare. As in spice-gtk, subject verification substitutes for hostname verification (the existingNotValidForNamerelaxation stays). The verifier must also be installed whenhost_subjectis set without a custom CA — today the custom verifier only exists in the custom-CA path. - Fail closed. A malformed expected-subject string,
an unparseable end-entity certificate subject, or a
mismatch all fail the TLS handshake (surfaced as
CertificateError::NotValidForNamewith a descriptive log line). A security control that silently skips on bad input is worse than none. - No new heavyweight dependencies. Subject DN
parsing uses
x509-parser0.18, which is already in the ryll workspace lockfile (via rcgen 0.14) — it becomes a direct dependency of the protocol crate. Tests mint certificates withrcgen0.14 as a dev-dependency, matching ryll and kerbside precedent. The expected-subject parser gets a cargo-fuzz target alongside the existing three. - Enforcing from day one, in both consumers. No
config gate and no log-only grace period: there are no
production deployments to disrupt, matching the
firewall precedent from PLAN-rust-proxy. For ryll this
is a deliberate behaviour change —
.vvfiles with a stalehost-subjectwill now fail — and is called out in its changelog. - End-to-end proof in CI. The direct-qemu lane gains
a TLS-enabled backend: qemu's SPICE server presents a
certificate with a known subject, the mock gRPC server
supplies matching and deliberately mismatching
host_subjectvalues, and the lane asserts both the happy path (session relays) and the refusal path (connection refused, audit event emitted).
Open questions¶
- How deep to take canonicalisation parity with
X509_NAME_cmp: proposed is ASCII case-insensitive comparison with internal whitespace collapsed, applied to UTF8String/PrintableString values. Full nameprep-style canonicalisation is out of scope. - Which attribute short names the parser accepts:
proposed is the set spice deployments actually use
(
CN,O,OU,C,L,ST,emailAddress), with unknown keys a parse error (fail closed). To be finalised in phase-1 planning against x509-parser's OID registry. - Whether a subject mismatch should emit a distinct audit event type, or reuse the generic hypervisor-connection- failed event with a distinguishing message (the Python proxy did the latter). Lean: reuse with a clear message; decide in phase-2 planning.
- Whether the oVirt-supplied
host.certificate.subjectstring format (separator, spacing) matches the spice-common parse rules exactly, or needs normalisation at scrape time. ANSWERED in phase 2: the live value isO=local,CN=ovirt.local, which parses cleanly — no normalisation needed. - Whether ryll should surface subject-verification failures to its GUI users with a friendlier error than a TLS handshake failure. Ryll-side polish, not blocking.
Execution¶
| Phase | Plan | Status |
|---|---|---|
| 1. Subject pinning in shakenfist-spice-protocol (ryll repo) | PLAN-host-subject-phase-01-ryll-verifier.md | Complete (merged to ryll develop, ryll PR #166) |
| 2. Kerbside adoption: pin bump, CI proof, docs | PLAN-host-subject-phase-02-kerbside-adoption.md | Complete (kerbside PR #114; CI proof green, oVirt format verified) |
Phase sketches (each to be expanded into its own plan file before execution):
- Subject pinning in the crate (ryll repo). Factor a
host_subjectmodule inshakenfist-spice-protocol: parse the expected-subject string (spice-common semantics) and compare against an end-entity certificate's subject DN viax509-parser. TeachSpiceCaVerifierto run the comparison after chain verification whenConnectionConfig.host_subjectis set, and install the custom verifier in the no-custom-CA case too when a subject is pinned. Update thelib.rsdoc comments that currently promise non-enforcement. Unit tests with rcgen-minted certs (match; value case difference — accept; RDN order difference — reject; missing/extra RDN — reject; escaped comma in a value; malformed expected string — reject); afuzz_parse_host_subjectfuzz target; a changelog note for the ryll behaviour change. Land on ryll develop. - Kerbside adoption. Bump the
revpin inrust/kerbside-proxy/Cargo.tomlto the ryll commit; delete theTODO(host_subject)comment inbackend.rsand extend its unit tests; verify the audit path surfaces refusals (parity with the Python proxy's "Hypervisor connection failed"); extend the direct-qemu CI lane with a TLS-enabled qemu SPICE backend and positive/negative host_subject assertions; correctdocs/proxy-architecture.mdanddocs/console-sources.md; check the oVirt subject string format question above.
Agent guidance¶
Execution model¶
All implementation work is done by sub-agents, never in the management session. The management session (this conversation) is reserved for planning, review, and decision-making. This keeps the management context lean and avoids drowning it in implementation diffs.
The workflow is:
- Plan at high effort in the management session.
- Spawn a sub-agent for each implementation step with the brief from the plan, at the recommended effort level and model.
- Review the sub-agent's output in the management session. Check the actual files — the sub-agent's summary describes what it intended, not necessarily what it did.
- Fix or retry if the output is wrong. Diagnose whether the brief was insufficient (improve it) or the model was too light (upgrade it), then re-run.
- Commit once the management session is satisfied with the result.
This applies to all steps, including high-effort ones. If a sub-agent can't succeed even with a detailed brief and the right model, that's a signal the brief needs improving, not that the management session should do the implementation itself.
Use isolation: "worktree" for sub-agents when the change
is risky or experimental. The worktree is discarded if the
output is unsatisfactory. For safe, well-understood
changes, sub-agents can work directly in the main tree.
Planning effort¶
The master plan itself should always be created at high effort — it requires broad codebase understanding, cross-referencing multiple source files, and making judgment calls about scope and sequencing.
Phase 1 should be planned at high effort: it encodes wire-adjacent security semantics (DN parsing and comparison rules cross-checked against spice-common) where a subtle divergence silently weakens or breaks the control. Phase 2 can be planned at medium effort for the pin bump and docs, but the CI fixture work (TLS-enabled qemu SPICE backend) should be treated as high-effort within that plan.
Step-level guidance¶
Each phase plan should include a table like this:
| Step | Effort | Model | Isolation | Brief for sub-agent |
|------|--------|--------|-----------|---------------------|
| 1a | medium | sonnet | none | One-sentence summary of what to do and which files to touch |
| 1b | high | opus | worktree | Why this needs high effort: requires understanding X to do Y |
Effort levels: - high — Requires reading multiple files, making judgment calls, understanding non-obvious invariants (X.509 DN encoding, rustls verifier contracts, TLS handshake failure surfaces, audit log guarantees), or researching external references. The sub-agent needs to think carefully about edge cases. - medium — The plan provides enough context that the sub-agent can follow a clear brief. May need to read a few files but the approach is well-defined. - low — Purely mechanical changes (rename, reformat, add a log line). The brief is a complete instruction.
Model choice: The planner should recommend which model is best suited for each step. This is a judgment call, not a rigid rule — the right model depends on what the step requires, not on whether it's "planning" or "implementation".
- opus — Best for steps that require deep reasoning, cross-file architectural understanding, subtle correctness judgment, security-sensitive parsing and comparison logic, or intricate implementation where getting it wrong would be costly to debug (a subject matcher that accepts too much is an invisible security hole; one that accepts too little is a production outage).
- sonnet — Good default for well-briefed implementation work. Faster and cheaper than opus. Works well when the plan front-loads the research and the brief is detailed enough that the agent doesn't need to make broad judgment calls.
- haiku — Suitable for purely mechanical tasks: search-and-replace, adding log lines, running commands. The brief must be a near-complete instruction.
The model choice interacts with effort level and brief quality. A detailed brief compensates for a lighter model — sonnet at medium effort with a thorough brief often matches opus at medium effort with a vague brief. The planner's job is to write briefs good enough that the recommended model can succeed.
Note: the model also determines the context window (opus has 1M tokens, sonnet and haiku have 200K). Steps that require holding many files in context simultaneously may need opus for that reason alone, even if the reasoning itself is straightforward.
When in doubt, skew to the more capable model. Saving money only matters if the outcome is still acceptable. A failed or low-quality implementation wastes more time (and therefore more money) than using a heavier model would have cost. Only recommend a lighter model when you are confident the brief is detailed enough for it to succeed.
Brief for sub-agent: This is the key field. Write it as if briefing a colleague who has never seen the codebase. Include: what to change, which files to touch, what patterns to follow, and any non-obvious constraints. The better the brief, the lower the effort level needed and the lighter the model that can succeed.
A good brief front-loads the research the planner
already did, so the implementing agent doesn't repeat it.
For example, instead of "make the verifier check the
subject", write "in
shakenfist-spice-protocol/src/client.rs, extend
SpiceCaVerifier (struct at line ~33, verify_server_cert
at ~47) with an expected_subject: Option<ParsedSubject>
field populated from ConnectionConfig.host_subject
(lib.rs:93) in create_tls_connector (~137); after the
existing webpki match, parse end_entity with
x509_parser::X509Certificate::from_der and compare per
the rules in the plan's design decision 1, returning
Error::InvalidCertificate(CertificateError::NotValidForName)
on mismatch."
Management session review checklist¶
After a sub-agent completes, the management session should verify:
- The files that were supposed to change actually changed (read them, don't trust the summary).
- No unrelated files were modified.
- Python changes pass
tox -eflake8andtox -epy3. - Rust changes pass
cargo fmt --check,cargo clippy, andcargo testin the affected workspace; new parsers have fuzz targets and the fuzz targets at least build. - Matching semantics are cross-checked against
/srv/src-reference/spice/spice-common/common/ssl_verify.c— not against memory of it. - The changes match the intent of the brief — not just syntactically correct but semantically right.
- Commit message follows project conventions
(including the
Co-Authored-Byline with model, context window, effort level, and other settings).
Administration and logistics¶
Success criteria¶
We will know when this plan has been successfully implemented because the following statements will be true:
- A console with a
host_subjectset relays only to a backend presenting a certificate whose subject matches under the spice-common rules; a mismatched, extra-RDN, reordered, or malformed subject refuses the connection and the refusal is visible in audit events. - A console without a
host_subjectbehaves exactly as today. - The same subject string accepted or rejected by the
proxy is accepted or rejected identically by
virt-viewer/spice-gtk against the same certificate
(semantics parity, demonstrated by tests derived from
ssl_verify.c, not necessarily by running spice-gtk). - Ryll headless connecting with a
.vvcontaininghost-subjectenforces it. - The direct-qemu CI lane exercises a TLS backend with both matching and mismatching subjects on every PR.
- The expected-subject parser has a cargo-fuzz target;
the Rust code passes
cargo fmt --check,cargo clippywithout warnings, andcargo testin both repos; any Python touched passestox -eflake8andtox -epy3. rust/kerbside-proxy/Cargo.tomlpins a ryllrevcontaining the enforcement, and theTODO(host_subject)comment inbackend.rsis gone.docs/proxy-architecture.mdanddocs/console-sources.mdaccurately describe the behaviour (the former currently over-claims), and ryll's changelog records the behaviour change.- PLAN-rust-proxy.md's future-work entry for backend
host_subjectenforcement is marked done with a pointer to this plan.
Future work¶
- Per-source or per-console policy on requiring a host_subject (today a NULL subject silently disables the check; a paranoid deployment may want "no subject, no connection").
- Surfacing subject-verification failures in ryll's GUI with a friendlier error.
- An OpenStack source scraper does not exist today; if one is added, wire subject scraping into it from the start.
- Consider crates.io publication of
shakenfist-spice-protocolinstead of git-rev pinning (deferred from PLAN-rust-proxy).
Bugs fixed during this work¶
None yet. When planning phase 1, scan the
shakenfist/kerbside and shakenfist/ryll issue trackers
for open bugs related to backend TLS, certificate
handling, or host_subject, and either fold them into the
relevant phase or record them here as consciously
deferred. Known already: docs/proxy-architecture.md:57-60
documents behaviour that does not exist (fixed by phase
2).
Documentation index maintenance¶
When creating a new master plan from this template,
update the following file in docs/plans/:
index.md— add a row to the Master plans table with the creation date, a link to the plan, a one-line intent summary, the initial status, and links to each phase plan file. Keep the table in chronological order.
When all phases of a plan are complete, update the
status column in index.md to Complete.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.