TLS & PKI API
This guide is for authors of CLI, local web, Elixir, and other HTTP/JSON
clients that control Hovel workspace PKI through hoveld. The
machine-readable source of truth is the
OpenAPI 3.1 contract; the
transport and compatibility policy is documented in the
Daemon RPC Contract.
POST under
/hovel.daemon.v1.DaemonService/. The current daemon has no
public authentication scheme or HTTPS listener. Do not expose it through a
reverse proxy and do not rewrite the methods as an invented
/api/v1 resource API.
Boundary at a Glance
CLI / local web / Elixir / generated REST client
|
| HTTP POST + JSON
v
owner-only Unix socket loopback TCP
inventory + privileged calls status + public capabilities
|
v
hoveld application API
inventory | journals | approvals
|
+-----------+----------------+
| |
daemon-owned SQLite module provider process
and master-key custody framed JSON-RPC over stdio
| |
+----- never opened by client+
The Go client in core/internal/adapters/daemonrpc is an internal
implementation, not an importable public SDK. The checked-in Go, Python,
and Rust SDKs are for module processes speaking framed JSON-RPC over stdio;
they are not daemon HTTP clients. There is no checked-in Elixir daemon SDK.
External clients should generate from OpenAPI or implement the small unary
HTTP contract directly.
OPTIONS handling for cross-origin
loopback calls. A local web or Elixir-controlled UI therefore needs a trusted
same-origin backend or native sidecar that owns the Unix-socket connection,
authenticates its own clients, enforces CSRF and authorization policy, and
exposes only the operations the UI needs. Do not publish or reverse-proxy the
daemon listener itself.
Choose the Correct Transport
| Transport | Current authority | PKI behavior | Client rule |
|---|---|---|---|
| Unix socket | Confidential, owner-only local boundary; the daemon sets mode 0600 |
Read methods, mutations, signing-lease control, Mesh mutations, and eligible secret responses | Use this transport for every state change or credential-bearing operation |
| Loopback TCP over HTTP | Non-sensitive reads only; only loopback hosts are accepted | PKIStatus, backend/profile capability discovery, and other explicitly public projections; PKI inventory, assignments, trust, revocation, credential bookkeeping, and privileged calls return 403 permission-denied |
Use only for public capability discovery; reconnect through the Unix socket for inventory or mutation |
| HTTPS or non-loopback TCP | Not implemented | The endpoint parser rejects it | Build a separate authenticated adapter rather than weakening this boundary |
“Confidential” here means an owner-only local Unix socket, not TLS on the
daemon listener. Supplying actorId or an approval boolean does
not turn TCP into a privileged transport.
Wire rules
- Use the exact RPC path and lower-camel PKI field names shown in OpenAPI.
- Send one JSON value with
Content-Type: application/json. Unknown request fields and trailing JSON are rejected. - Keep the JSON body at or below
64 MiB(67,108,864bytes). The daemon applies that inclusive limit while reading rather than rejecting fromContent-Lengthalone; a decode that crosses it returns plain-text413. - Send
{}for an empty request. Do not omit the body. - Go
[]bytefields such as certificate DER, CRL DER, and bundle binary data are base64 JSON strings. - Timestamps are RFC 3339 date-time strings.
- IDs are canonical strings of at most 256 bytes. Domain IDs accept letters, digits, and
-_.:/; retain returned IDs rather than reconstructing them. - Generations and revisions are positive signed-64-bit-compatible integers, up to
9223372036854775807. - Requests are strict, but response readers should ignore additive fields they do not yet understand.
int64 revisions, which can exceed the
exact range of a JavaScript Number. Use a lossless decoder or
fail explicitly rather than silently rounding a revision and defeating a
concurrency fence.
Request Context and Explicit Approvals
PKI mutations carry a context object. Its three required IDs
identify who requested the call, the stable operator operation that owns
it, and the individual call or workflow correlation. They are audit scope,
not free-form authorization claims:
{
"actorId": "operator-7",
"operationId": "credential-rollout-edge",
"correlationId": "change-1042"
}
PKIRequestContext field |
Currently consumed by | Meaning |
|---|---|---|
approveSigningLease |
UnlockPKIAuthority |
Explicitly permits an actor- and operation-scoped signing lease; it does not unlock every caller |
approvePrivateKeyExport |
ExportPKIBundle with includePrivate: true |
Approves this private export request; the certificate export policy must also allow it |
approveCredentialUse |
Credential-bearing StartMeshListener, direct survey RunMeshTask, OpenMeshStream, and OpenMeshBridge |
Permits assignment material to be delivered into the same provider process that performs the Mesh call; no secret is returned to the control-plane client |
approveCrlPublicationReconciliation |
ReconcilePKICRL and ReconcilePKICRLs |
Permits conservative recovery of stale publication plans |
approveIssuanceReconciliation |
No current daemon HTTP method | An application-level extension point; do not treat the field itself as an implemented recovery endpoint |
Omit unrelated approval fields. Sending every approval as true
defeats operator review and makes audit intent ambiguous. Workspace
initialization has its own top-level confirmed: true field; it
is not implied by a context approval.
Stable Identity, Revisions, and Idempotency
| Resource | Stable identity | Concurrency or recovery state |
|---|---|---|
| Authority | authority.id |
activeGenerationId identifies the exact signing certificate generation |
| Certificate | certificateId is the lineage; id is one immutable generation |
generation increases without changing earlier generations |
| Assignment | assignment.id |
revision fences stage, activate, and unbind; active and staged certificate/trust IDs remain explicit |
| Trust set | trustSet.id |
revision fences mutation; immutable trust generations carry exact anchor, intermediate, and CRL generation IDs |
| CRL publication | publication.id for the signing plan and generation.id for the immutable CRL |
Publication status, phase, revision, and lease support crash recovery |
| Authority rollover | operation.id |
Operation and trust-set revisions fence transitions; acknowledgement IDs and missingAssignmentIds are durable bookkeeping |
| Credential delivery | Selector requestId, credential execution ID, or stamp ID |
List/inspect records expose state, hashes, and sizes without secret bytes, provider references, or protected paths |
Most synchronous mutations and issuance operations accept
idempotencyKey. An explicit key is scoped by actor and mutation
kind. Reusing the same scoped key with the same normalized request returns
the recorded public result; reusing it with a different request returns
idempotency-conflict. If the field is omitted, Hovel derives a
key from actor, operation, correlation, mutation kind, and the request
digest. Therefore an implicit-key retry must preserve all three context IDs
and the exact request.
expectedRevision.
Implemented Method Families
| Purpose | Non-sensitive loopback reads | Owner Unix-socket methods |
|---|---|---|
| Workspace and capabilities | PKIStatus, ListPKIBackends, ListPKIProfiles |
InitializePKI |
| Authorities and certificates | None | ListPKIAuthorities, InspectPKIAuthority, ListPKICertificates, InspectPKICertificate, CreatePKIAuthority, UnlockPKIAuthority, LockPKIAuthority, IssuePKICertificate, RenewPKICertificate, RotatePKICertificate |
| Revocation and CRLs | None | InspectPKIRevocation, InspectPKIGenerationRevocation, ListPKIRevocations, InspectPKICRL, ListPKICRLs, InspectPKICRLPublication, ListPKICRLPublications, RevokePKICertificate, PublishPKICRL, ReconcilePKICRL, ReconcilePKICRLs |
| Assignments and trust | None | ListPKIAssignments, InspectPKIAssignment, ListPKITrustSets, InspectPKITrustSet, BindPKIAssignment, StagePKIAssignment, ActivatePKIAssignment, UnbindPKIAssignment, CreatePKITrustSet, StagePKITrustSet, ActivatePKITrustSet |
| Durable bookkeeping | None | ListPKIOperations, InspectPKIOperation, ListPKICredentialStamps, InspectPKICredentialStamp, ListPKICredentialExecutions, InspectPKICredentialExecution, and the six authority-rollover transitions |
| Bundle and Mesh use | DescribeMesh |
ExportPKIBundle; Mesh topology, beacon, listener, and operation reads; and Mesh listener/task/stream/bridge mutations |
The default daemon composition currently registers the
builtin-x509 backend. Treat ListPKIBackends as the
runtime authority for algorithms and capabilities; do not hard-code a
hardware, cloud, or imported-key provider that is not returned there.
Copyable HTTP Examples
The examples use curl only to make the wire visible. They do not
imply any Hovel CLI flags. Set the socket and logical base URL once:
SOCKET=/absolute/path/to/hoveld.sock
RPC=http://hoveld/hovel.daemon.v1.DaemonService
Read an assignment and retain its revision
curl --silent --show-error \
--unix-socket "$SOCKET" \
--header 'Content-Type: application/json' \
--data '{"id":"assignment-rpc"}' \
"$RPC/InspectPKIAssignment"
A conforming response can be decoded as:
HTTP/1.1 200 OK
Content-Type: application/json
{
"assignment": {
"id": "assignment-rpc",
"purpose": "mtls-server",
"consumerType": "mesh-listener",
"consumerId": "mesh-provider/listener-rpc",
"profileId": "mtls-server",
"activeGenerationId": "generation-rpc-listener",
"trustSetId": "trust-rpc",
"activeTrustGenerationId": "trust-rpc-generation-1",
"state": "active",
"revision": 3,
"updatedAt": "2026-07-12T18:00:00Z"
}
}
Use the returned revision as the next mutation fence. Do not
infer it from the number of list entries or from wall-clock time.
Activate a staged assignment generation
curl --silent --show-error \
--unix-socket "$SOCKET" \
--header 'Content-Type: application/json' \
--data-binary @- \
"$RPC/ActivatePKIAssignment" <<'JSON'
{
"context": {
"actorId": "operator-7",
"operationId": "credential-rollout-edge",
"correlationId": "change-1042"
},
"request": {
"idempotencyKey": "change-1042:activate:assignment-rpc",
"assignmentId": "assignment-rpc",
"expectedRevision": 2
}
}
JSON
The success type is PKIAssignmentInspection, the same resolved
shape used by InspectPKIAssignment. Preserve the exact request
above for an ambiguous retry.
Select assignment credentials on a Mesh mutation
curl --silent --show-error \
--unix-socket "$SOCKET" \
--header 'Content-Type: application/json' \
--data-binary @- \
"$RPC/StartMeshListener" <<'JSON'
{
"moduleId": "mesh-provider@v0.1.0",
"request": {
"listenerId": "listener-edge",
"name": "Edge listener",
"config": {
"bindHost": "127.0.0.1",
"bindPort": 8443
}
},
"credentials": [
{
"requestId": "credential-listener-edge-certificate-1",
"assignmentId": "assignment-rpc",
"slotName": "control-plane-mtls-certificate",
"capability": "runtime",
"material": {
"projection": "certificate-der",
"form": "public"
}
},
{
"requestId": "credential-listener-edge-private-key-1",
"assignmentId": "assignment-rpc",
"slotName": "control-plane-mtls-private-key",
"capability": "runtime",
"material": {
"projection": "private-key-pkcs8",
"form": "private-bytes"
}
}
],
"credentialContext": {
"actorId": "operator-7",
"operationId": "mesh-listener-edge-start",
"correlationId": "change-1042",
"approveCredentialUse": true
}
}
JSON
HTTP/1.1 200 OK
Content-Type: application/json
{
"operationId": "mesh-operation-listener-edge-start",
"listener": {
"id": "listener-edge",
"name": "Edge listener",
"state": "active",
"updatedAt": "2026-07-12T18:05:00Z"
}
}
credentials and credentialContext are a pair: omit
both when no assignment material is needed, and send both otherwise. The
current external selector path implements only
capability: runtime with these projection/form pairs:
| Projection | Form |
|---|---|
certificate-der | public |
public-key-spki | public |
private-key-pkcs8 | private-bytes |
Each requestId is an at-most-once credential-delivery identity,
not a replay key for the enclosing Mesh call. An array cannot repeat a
request ID or the same assignment-and-slot pair. Reusing an ID prevents a
second provider delivery, but a terminal delivery cannot be replayed into a
fresh provider process and Hovel does not persist the enclosing Mesh result.
After an ambiguous timeout, inspect Mesh operations and credential-execution
records before deciding whether it is safe to start a new call; a deliberate
new attempt requires fresh request IDs. Provider descriptors must therefore
expose separate slots for a TLS identity's
certificate-der/public and
private-key-pkcs8/private-bytes selections; never
select both projections through one slot. Provider
configuration is write-only and credentials are resolved immediately
before the call; neither is echoed in the listener response or operation
ledger. File delivery, provider encoding, stamping, bundle projection, and
signer-reference contracts exist elsewhere as provider extension points,
but are not accepted by this Mesh HTTP selector.
Private bundle export is never cacheable
POST /hovel.daemon.v1.DaemonService/ExportPKIBundle HTTP/1.1
Host: hoveld
Content-Type: application/json
{
"context": {
"actorId": "operator-7",
"operationId": "credential-export-edge",
"correlationId": "change-1042-export",
"approvePrivateKeyExport": true
},
"generationId": "generation-rpc-listener",
"purpose": "mtls-server",
"includePrivate": true
}
A private response is possible only through the Unix socket, for an active
generation whose purpose matches and whose export policy is
explicit-private-export. The route sends
Cache-Control: no-store on success and failure, including when
includePrivate is false. A denied response has this typed shape:
HTTP/1.1 403 Forbidden
Cache-Control: no-store
Content-Type: application/json
{
"version": "v1",
"code": "private-key-export-denied",
"message": "pki: private key export is not allowed"
}
Authority Rollover
Rollover is one durable daemon operation, not a sequence that a front end should infer from authority states. The operation records the exact previous and replacement authority generation IDs, the overlap and final trust generations, required assignments, acknowledgements, phase, and revision.
stage overlap trust
|
v
StartPKIAuthorityRollover
awaiting-overlap-acknowledgements
|
| Acknowledge... for every required assignment
v
ActivatePKIAuthorityRollover [operation + trust revisions]
awaiting-leaf-rotation
|
| issue, stage, and activate replacement leaves
| stage final trust
v
BeginPKIAuthorityRolloverFinalTrust [operation + trust revisions]
awaiting-final-acknowledgements
|
| Acknowledge... for every required assignment
v
CompletePKIAuthorityRollover [operation + trust revisions]
completed
A copyable start request using explicit consumer tracking is:
curl --silent --show-error \
--unix-socket "$SOCKET" \
--header 'Content-Type: application/json' \
--data-binary @- \
"$RPC/StartPKIAuthorityRollover" <<'JSON'
{
"context": {
"actorId": "operator-7",
"operationId": "rollover-edge-root-2026",
"correlationId": "change-1042"
},
"request": {
"idempotencyKey": "change-1042:rollover:start",
"operationId": "rollover-edge-root-2026",
"previousAuthorityId": "authority-edge-root-old",
"replacementAuthorityId": "authority-edge-root-new",
"trustSetId": "trust-edge",
"overlapTrustGenerationId": "trustgen-edge-overlap",
"consumerTracking": "explicit",
"requiredAssignmentIds": [
"assignment-edge-listener"
]
}
}
JSON
HTTP/1.1 200 OK
Content-Type: application/json
{
"operation": {
"id": "rollover-edge-root-2026",
"kind": "authority-rollover",
"status": "waiting",
"revision": 1,
"authorityRollover": {
"previousAuthorityId": "authority-edge-root-old",
"previousAuthorityGenerationId": "certgen-edge-root-old",
"replacementAuthorityId": "authority-edge-root-new",
"replacementAuthorityGenerationId": "certgen-edge-root-new",
"trustSetId": "trust-edge",
"overlapTrustGenerationId": "trustgen-edge-overlap",
"consumerTracking": "explicit",
"requiredAssignmentIds": [
"assignment-edge-listener"
],
"phase": "awaiting-overlap-acknowledgements"
},
"createdAt": "2026-07-12T18:10:00Z",
"updatedAt": "2026-07-12T18:10:00Z"
},
"acknowledgements": [],
"missingAssignmentIds": [
"assignment-edge-listener"
]
}
Persist the returned authority generation IDs and operation revision. Before
every transition, call InspectPKIOperation and
InspectPKITrustSet; submit both current revisions where the
request schema requires them. Use missingAssignmentIds rather
than counting acknowledgements yourself. A rollover reserves its trust set
and required assignments, so unrelated mutations can return the typed
resource-reserved precondition. Cancellation is implemented
only before overlap activation.
CRL Publication Recovery
PublishPKICRL persists a plan before invoking a signer. Keep the
returned publication ID distinct from the resulting immutable CRL
generation ID. Use ListPKICRLPublications or
InspectPKICRLPublication to observe the plan, and
ListPKICRLs or InspectPKICRL to observe completed
CRL generations.
A crash may leave a publication pending in phase
planned, signing, or signed. Wait until
both the requested stale age and the recorded ownership lease have elapsed,
then reconcile through the confidential transport:
curl --silent --show-error \
--unix-socket "$SOCKET" \
--header 'Content-Type: application/json' \
--data-binary @- \
"$RPC/ReconcilePKICRL" <<'JSON'
{
"context": {
"actorId": "operator-7",
"operationId": "pki-recovery",
"correlationId": "reconcile-crl-2026-0712",
"approveCrlPublicationReconciliation": true
},
"request": {
"publicationId": "crl-publication-018f6e2f",
"staleAfterSeconds": 300
}
}
JSON
staleAfterSecondsis between 60 and 86400. Batch recovery additionally requireslimitbetween 1 and 100.- A durable
signedcheckpoint is independently validated and completed without calling the signer again. - An ambiguous
plannedorsigningplan is failed rather than risking a second signing invocation. - A non-stale or still-leased plan returns
crl-publication-in-progress; back off and inspect it again. - Reconciliation repairs publication bookkeeping. It does not make an expired CRL fresh; publish a new CRL and stage a new trust generation when freshness has elapsed.
Typed Errors
Classified application failures use the versioned
RPCError envelope. Branch on version,
code, and, for rollover failures,
rolloverReason. The message and optional
rolloverDetail are diagnostics for people, not a protocol.
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"version": "v1",
"code": "rollover-precondition",
"message": "pki: authority rollover precondition failed: assignments-not-rotated: assignment drifted after acknowledgement",
"rolloverReason": "assignments-not-rotated",
"rolloverDetail": "assignment drifted after acknowledgement"
}
| HTTP | Stable code | Client action |
|---|---|---|
| 404 | not-found | Refresh inventory; do not invent or parse an ID from the message |
| 403 | permission-denied | Reconnect through the Unix socket; repeating on TCP cannot succeed |
| 403 | private-key-export-denied | Stop automatic retry and require an explicit operator decision |
| 403 | authority-signing-lease-owned | Do not replace or revoke another actor or operation's live lease; wait for expiry or coordinate with that owner |
| 423 | authority-signing-locked | Acquire an explicitly approved signing lease for the same actor and operation, then retry the unchanged idempotent request |
| 409 | revision-conflict | Inspect, re-evaluate, and use a new idempotency key if the request changes |
| 409 | idempotency-conflict | Treat as a client bug or changed intent; never mutate the request under the same key |
| 409 | issuance-in-progress, crl-publication-in-progress, or mutation-exists | Back off, inspect bookkeeping, and retry the exact request with the same key |
| 409 | acknowledgement-exists | Inspect the operation and use the recorded acknowledgement |
| 409 | rollover-precondition | Branch on the stable reason and re-inspect the operation, assignments, authorities, and trust set |
Invalid JSON returns plain-text 400, a body read that exceeds
64 MiB returns plain-text 413, a non-POST request
returns plain-text 405, and an unclassified server failure currently
returns plain-text 500. The size cap is enforced while decoding,
not by preflighting Content-Length; malformed input rejected before
the decoder crosses the cap can therefore still produce 400.
Although internal is a
reserved error code in the schema, the current handler does not promise a
typed envelope for unclassified failures. Treat any non-conforming error
body as opaque.
message, plain-text bodies, or logs.
If a response is not a valid v1 envelope, retain only its HTTP
status and an opaque diagnostic for display.
Minimal Client Algorithm
call(method, request, call_class):
1. Choose transport:
public status/capability method -> Unix socket or loopback TCP
inventory, bookkeeping, mutation, or secret-bearing -> owner-only Unix socket
2. Validate the request against OpenAPI; emit exact field names and one JSON value.
3. For a journaled PKI mutation, allocate an idempotency key before the first send
and retain method + actor + exact request + key + relevant resource revisions.
For credential-bearing Mesh, allocate unique request IDs, but treat them as
at-most-once delivery claims rather than replayable mutation keys.
4. POST /hovel.daemon.v1.DaemonService/{method}.
5. On 2xx, decode the documented response, retain returned IDs/revisions,
and ignore unknown additive response fields.
6. On a valid v1 RPCError, branch on code and rolloverReason, never message.
7. On timeout or opaque 5xx:
idempotent read -> retry with bounded backoff on its authorized transport;
journaled PKI mutation -> inspect durable bookkeeping, then retry only the
exact request with the same idempotency key;
Mesh mutation -> inspect Mesh operations and credential executions;
do not replay automatically because the result and
provider-process state are not journaled.
8. If a PKI inspection changes the intended request, obtain operator confirmation
as appropriate and allocate a new idempotency key. If a new Mesh attempt is
explicitly judged safe, use fresh credential request IDs for its new process.
Storage and Extension Boundaries
<workspace>/workspace.db, its SQLite
sidecars, or
<workspace>/secrets/pki-master-keys.json. Those files are
daemon-owned implementation details. Direct access bypasses request
approvals, revision fences, idempotency journals, audits, integrity checks,
and the daemon's exclusive database lifecycle.
Use list and inspect methods even for local tools. In particular, use operation inspection for rollover, publication inspection for CRL recovery, and credential stamp/execution inspection for provider bookkeeping. These are the supported recovery surfaces; a SQLite query is not.
Provider registries, additional credential-delivery capabilities, and a future authenticated public adapter are extension points, not current external-interface promises. Generate clients from the current OpenAPI, discover backends at runtime, and add behavior only when a method and schema appear in that contract.