Step-CA JWK encryptedKey Corruption — Python Dict String in ca.json
Summary
After deploying the Step-CA role to fablehaven, the step-ca systemd
service failed to start. Investigation revealed that ca.json contained
a malformed encryptedKey field — the JWK provisioner entry had been
written as a Python dict string representation (single quotes, True
instead of true) rather than valid JSON.
The corruption originated in vault_edit.py, which serialized the JWK
dict using Python's str() instead of json.dumps(). The Ansible
vault contained a Python repr string, which was templated directly into
ca.json.j2 and written to disk.
Date: 2026-05-06 Severity: High — Step-CA non-functional, fleet cert issuance blocked Resolution: mistborn-ops/ansible #380, #467
Situation
TheBurrow's internal PKI runs Step-CA on fablehaven as a systemd
service. The CA uses a JWK (JSON Web Key) provisioner to issue
certificates to fleet hosts. The JWK provisioner configuration —
including the encryptedKey field containing the encrypted private key
— is stored in Ansible Vault and templated into ca.json during
deployment.
The vault_edit.py script was used to add the JWK provisioner entry
to the vault. The script opened the vault, accepted the JWK value from
the operator, and wrote it back.
Task
Identify why Step-CA failed to start after deployment, restore the CA to a functional state, and prevent the corruption from recurring.
Symptoms
## On fablehaven
$ systemctl status step-ca
● step-ca.service - Step-CA Certificate Authority
Loaded: loaded (/etc/systemd/system/step-ca.service; enabled)
Active: failed (Result: exit-code)
$ journalctl -u step-ca -n 20
error parsing ca.json: invalid character '\'' looking for beginning of value
The CA refused to start with a JSON parse error pointing to a single quote character — the tell-tale sign of a Python repr string where JSON was expected.
Problem
The encryptedKey field in a JWK provisioner must be a valid JSON
string. A correctly formed entry looks like:
{
"type": "JWK",
"name": "fablehaven-provisioner",
"key": {
"use": "sig",
"kty": "EC",
"kid": "...",
"crv": "P-256",
"alg": "ES256",
"x": "...",
"y": "..."
},
"encryptedKey": "{\"kty\":\"EC\",\"use\":\"sig\",...}"
}
What was written to ca.json instead:
"encryptedKey": "{'kty': 'EC', 'use': 'sig', 'kid': '...', 'x': '...', 'y': '...', 'crv': 'P-256', 'alg': 'ES256', 'd': '...', 'key_ops': ['sign'], 'ext': True}"
Single quotes, True capitalized, colon-separated key-value pairs —
this is Python's repr() output for a dict, not JSON.
Investigation
Tracing the corruption path
The JWK value flowed through three stages:
- vault_edit.py — operator pastes JWK value, script writes to vault
- Ansible Vault — encrypted storage
- ca.json.j2 template — vault variable rendered into ca.json
Inspecting the vault directly (after decryption) revealed the
encryptedKey value was stored as a Python dict string:
vault_step_ca_jwk_encrypted_key: "{'kty': 'EC', 'use': 'sig', ...}"
The template ca.json.j2 rendered this value directly:
"encryptedKey": "{{ vault_step_ca_jwk_encrypted_key }}"
No JSON serialization was applied. The Python repr string landed
verbatim in ca.json.
Root cause in vault_edit.py
The script used Python's str() to serialize the value before writing
to the vault:
## Incorrect
vault_data[key] = str(user_input)
## Correct
vault_data[key] = json.dumps(user_input) if isinstance(user_input, dict) else user_input
When the operator pasted a JSON object, vault_edit.py parsed it with
json.loads() (producing a Python dict) and then serialized it back
with str() — producing a Python repr string instead of a JSON string.
Why it wasn't caught earlier
- The vault contains encrypted content — syntax errors are invisible until decryption
- The template rendered the value without validation
- No preflight assertion checked that the JWK value was valid JSON
before rendering
ca.json - The step-ca service only validates
ca.jsonat startup — not during Ansible deployment
Root Cause
vault_edit.py used str() instead of json.dumps() when
serializing dict values back to the vault, corrupting JSON content
to Python repr format. The corruption was invisible until Step-CA
attempted to parse ca.json at service start.
Resolution
Step 1 — Repair the vault entry
Manually decrypted the vault, corrected the encryptedKey value to
valid JSON (re-serialized with json.dumps()), and re-encrypted:
ansible-vault edit inventories/home/group_vars/all/vault_secrets_step_ca.yml
## Corrected encryptedKey to valid JSON string
Step 2 — Re-run the Step-CA role
Ran the step-ca Ansible role against fablehaven to regenerate ca.json
from the corrected vault value. Verified service started cleanly:
$ systemctl status step-ca
● step-ca.service - Step-CA Certificate Authority
Active: active (running)
Step 3 — Fix vault_edit.py serialization
Updated vault_edit.py to use json.dumps() for dict values:
## After fix
if isinstance(value, dict):
vault_data[key] = json.dumps(value)
else:
vault_data[key] = value
Step 4 — Add preflight assertion in Step-CA role
Added a preflight task to the step-ca role to validate the JWK value
is valid JSON before attempting to render ca.json:
- name: Assert JWK encryptedKey is valid JSON
ansible.builtin.assert:
that:
- vault_step_ca_jwk_encrypted_key | from_json is mapping
fail_msg: "vault_step_ca_jwk_encrypted_key is not valid JSON — check vault_edit.py output"
Step 5 — Automate corruption repair
Created an automation task in the step-ca role to detect and repair encryptedKey corruption without manual vault editing (ansible #467).
Result
- Step-CA service running on fablehaven
ca.jsoncontains valid JSON throughoutvault_edit.pycorrectly serializes dict values as JSON strings- Preflight assertion catches corruption before service deployment
- Fleet cert issuance restored for Pi-hole nodes, Caddy, and devpi
Impact
During the period Step-CA was non-functional:
- No new TLS certificates could be issued to fleet hosts
- Certificate renewal via the step-ca-client role was blocked
- Existing certificates continued to function (not yet expired)
- Caddy reverse proxy on fablehaven continued serving existing certs
The impact was bounded because the failure occurred immediately after initial CA deployment — no existing certs had been issued that needed renewal. If this had occurred mid-fleet with active certificates approaching expiry, the impact would have been more severe.
Security Implications
Corrupt CA configuration is a silent failure mode
A CA that fails to start is visible. A CA that starts but silently uses wrong provisioner configuration is not. The preflight assertion added to the role ensures structural correctness is validated before deployment, not discovered at runtime.
Vault content is opaque until use
Encrypted vault content cannot be validated without decryption. Errors
introduced at write time — wrong format, wrong type, wrong encoding —
are invisible until the value is consumed. This argues for validation
at both write time (vault_edit.py) and consume time (preflight
assertions in roles).
JWK material is high-value
The JWK provisioner private key is the credential that authorizes certificate issuance for the entire fleet. Corruption, exposure, or substitution of this value has PKI-wide impact. Extra validation and careful handling are warranted.
Failure Modes
What would have happened if existing certs had been near expiry
If fleet certificates had been within the renewal window when the CA went non-functional, automatic renewal via the step-ca-client systemd timer would have failed silently. Certificates would have expired, causing TLS errors across internal services — Caddy reverse proxy, Pi-hole admin UI, devpi, NetBox — until the CA was restored.
What would have happened if corruption went undetected
If the CA had somehow started with a malformed ca.json (e.g., in a
future version of step-ca that handled the parse error differently),
it might have operated in a degraded state — accepting requests but
using incorrect provisioner configuration. Certificates issued with
wrong provisioner metadata would have been structurally valid but
governance-incorrect.
Lessons Learned
Validate at write time, not just at read time. The corruption was
introduced at vault write time and only discovered at service start.
A validation step in vault_edit.py that checks JSON validity before
writing would have caught this immediately.
Preflight assertions are the last line of defense before deployment.
The step-ca role now asserts that the JWK value is valid JSON before
rendering ca.json. This costs one task and prevents a class of
deployment failures.
Python repr is not JSON. str(dict) and json.dumps(dict) produce
different output. Single quotes vs. double quotes, True vs. true,
None vs. null. These are not interchangeable. Any code that
serializes Python objects for use in JSON contexts must use
json.dumps().
High-value cryptographic material deserves explicit format contracts. The vault schema for JWK entries should document that the value must be a JSON string, not a Python object. Making the contract explicit reduces the chance of future tooling errors introducing the same corruption.
Related
- mistborn-ops/ansible #380 — initial vault repair
- mistborn-ops/ansible #467 — automated corruption repair
- mistborn-ops/ansible #318 — safe JWK vault variable handling
- mistborn-ops/ansible #367 — JWK provisioner bootstrap ADR
- newcomb-labs/engineering-journal-kb #448 — ADR: JWK provisioner bootstrap strategy
- newcomb-labs/engineering-journal-kb #467 (this issue)