Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

kipuka is structured as a Cargo workspace with six crates, each owning a distinct responsibility. This separation enforces module boundaries at the compilation level and allows operators to build only the features they need.

Workspace layout

flowchart TD
    clients["Clients"]:::client

    clients -->|"TLS + mTLS / OTP"| est
    clients -->|"CoAP / DTLS<br/>(UDP)"| coap

    subgraph core [" "]
        est["<b>kipuka-est</b><br/>axum HTTP · TLS · EST protocol<br/>auth · HA · admin API"]:::server
        coap["<b>kipuka-coap</b><br/>CoAP / DTLS transport<br/>RFC 9483 · RFC 7252"]:::server
    end

    subgraph modules [" "]
        otp["<b>kipuka-otp</b><br/>OTP lifecycle<br/>argon2id · rate limiting"]:::auth
        hsm["<b>kipuka-hsm</b><br/>PKCS #11 / HSM<br/>cryptoki FFI"]:::crypto
        util["<b>kipuka-util</b><br/>shared types · config<br/>ASN.1 · zeroize"]:::server
    end

    est --> otp
    est --> hsm
    est --> util
    coap --> util

    hsm --> dogtag
    dogtag["<b>kipuka-dogtag</b><br/>Dogtag PKI<br/>REST client"]:::infra

    otp --> db
    est --> db
    db[("sqlx<br/>SQLite · PostgreSQL<br/>MariaDB")]:::store

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef store  fill:#1f1a0a,stroke:#ca8a04,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0
    classDef infra  fill:#1f0a18,stroke:#db2777,color:#e2e8f0

    style core fill:none,stroke:none
    style modules fill:none,stroke:none

Crate responsibilities

CrateRole
kipuka-estCore server binary. Owns the axum HTTP router, TLS termination (rustls), EST protocol handlers (/cacerts, /simpleenroll, /simplereenroll, /serverkeygen, /fullcmc, /csrattrs), request authentication, CSR validation, certificate construction (via synta), the admin API, database access (sqlx), and the HA state machine.
kipuka-hsmPKCS #11 integration via the cryptoki crate. Provides a Signer trait implementation that delegates cryptographic operations to an HSM. Handles slot enumeration, session management, key lookup, and sign operations. Isolates all unsafe FFI behind a safe Rust API.
kipuka-otpOne-time password lifecycle: generation (CSPRNG), hashing (argon2id / bcrypt), storage, validation, rate limiting, and expiration. Exposes an internal API consumed by kipuka-est for enrollment authentication and by the admin API for token provisioning.
kipuka-utilShared types and configuration parsing. Owns kipuka.toml deserialization (via serde + toml), X.509 helper functions, ASN.1 OID constants, error types, and the zeroize-aware wrappers for sensitive data.
kipuka-dogtagREST client for Red Hat Certificate System / Dogtag PKI. Translates EST enrollment requests into Dogtag profile-based certificate issuance calls, delegating signing to a full CA back-end instead of local key material.
kipuka-coapCoAP/DTLS transport layer (RFC 7252 / RFC 9483) for constrained-device enrollment. The CoapDtlsServer opens a UDP listener with DTLS 1.2/1.3 encryption (via OpenSSL), parses CoAP messages, and dispatches EST operations through the EstHandler trait. Block-wise transfer (RFC 7959) splits large certificate payloads across multiple datagrams. The main crate provides a CoapEstHandler that bridges CoAP requests to the shared EST enrollment logic, so both HTTPS and CoAP paths use identical CA signing, CSR validation, and audit code.

Dependencies flow strictly downward: kipuka-est depends on all other crates; leaf crates (kipuka-util, kipuka-coap) depend on nothing project-internal except kipuka-util.


EST operation data flow

A certificate enrollment request traverses one of two transport paths – HTTPS or CoAP/DTLS – before reaching the shared EST enrollment logic.

HTTPS transport (default)

flowchart TD
    C["Client"]:::client

    subgraph transport ["Transport"]
        TLS["<b>rustls</b><br/>TLS 1.2 / 1.3 handshake<br/>mTLS client cert or<br/>OTP via HTTP Basic"]:::crypto
    end

    subgraph routing ["Routing"]
        ROUTER["<b>axum router</b><br/>/.well-known/est/{label}/simpleenroll<br/>/.well-known/est/{label}/simplereenroll<br/>/.well-known/est/{label}/cacerts<br/>/.well-known/est/{label}/serverkeygen"]:::server
        LABEL["<b>Label resolution</b><br/>look up [[est.label]] entry<br/>resolve bound [[ca]] config"]:::server
    end

    subgraph authn ["Authentication"]
        direction LR
        MTLS["<b>mTLS</b><br/>verify client<br/>certificate chain"]:::auth
        OTP["<b>OTP</b><br/>argon2id hash<br/>comparison"]:::auth
        GSS["<b>GSSAPI</b><br/>SPNEGO /<br/>Kerberos"]:::auth
    end

    subgraph issue ["Issuance"]
        CSR["<b>CSR parsing</b><br/>synta · key type · SANs<br/>policy validation"]:::server
        TBS["<b>Certificate construction</b><br/>X.509 TBS · serial from<br/>OsRng (CSPRNG)"]:::server
    end

    subgraph sign ["Signing"]
        direction LR
        FILE["<b>File key</b><br/>synta direct<br/>signing"]:::crypto
        HSM["<b>HSM</b><br/>PKCS#11<br/>session"]:::crypto
        DOG["<b>Dogtag</b><br/>REST API<br/>delegation"]:::infra
    end

    RESP["<b>Response</b><br/>PKCS#7 / CMS envelope<br/>DER-encoded"]:::server
    AUDIT["<b>Audit</b><br/>file · syslog · database"]:::store

    C --> TLS
    TLS --> ROUTER
    ROUTER --> LABEL
    LABEL --> authn
    MTLS --> CSR
    OTP --> CSR
    GSS --> CSR
    CSR --> TBS
    TBS --> sign
    FILE --> RESP
    HSM --> RESP
    DOG --> RESP
    RESP --> AUDIT

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef store  fill:#1f1a0a,stroke:#ca8a04,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0
    classDef infra  fill:#1f0a18,stroke:#db2777,color:#e2e8f0

    style transport fill:none,stroke:#30363d,stroke-dasharray:5 5
    style routing fill:none,stroke:#30363d,stroke-dasharray:5 5
    style authn fill:none,stroke:#30363d,stroke-dasharray:5 5
    style issue fill:none,stroke:#30363d,stroke-dasharray:5 5
    style sign fill:none,stroke:#30363d,stroke-dasharray:5 5

CoAP/DTLS transport (constrained devices)

flowchart TD
    D["IoT device"]:::client

    subgraph transport ["Transport"]
        DTLS["<b>OpenSSL</b><br/>DTLS 1.2 / 1.3<br/>PSK or certificate mode"]:::crypto
    end

    subgraph coap_parse ["CoAP processing"]
        COAP["<b>CoAP parser</b><br/>method · URI path · Content-Format"]:::server
        BLK["<b>Block-wise transfer</b><br/>RFC 7959 · reassemble<br/>incoming blocks"]:::server
    end

    subgraph paths ["CoAP → EST path mapping"]
        direction LR
        SEN["/sen → SimpleEnroll"]:::server
        SREN["/sren → SimpleReenroll"]:::server
        SKG["/skg → ServerKeygen"]:::server
        ATT["/att → CsrAttrs"]:::server
        CRTS["/crts → CaCerts"]:::server
    end

    EST["<b>CoapEstHandler</b><br/>shared EST logic<br/>(same as HTTPS steps 5–9)"]:::server
    RESP["<b>CoAP 2.05 Content</b><br/>raw DER · Content-Format ID"]:::server
    AUDIT["<b>Audit log</b>"]:::store

    D -->|"UDP :5684"| DTLS
    DTLS --> COAP
    COAP --> BLK
    BLK --> paths
    SEN --> EST
    SREN --> EST
    SKG --> EST
    ATT --> EST
    CRTS --> EST
    EST --> RESP
    RESP --> AUDIT

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef store  fill:#1f1a0a,stroke:#ca8a04,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0

    style transport fill:none,stroke:#30363d,stroke-dasharray:5 5
    style coap_parse fill:none,stroke:#30363d,stroke-dasharray:5 5
    style paths fill:none,stroke:#30363d,stroke-dasharray:5 5

Both paths converge on the same CSR validation, certificate construction, and signing logic — the EstHandler trait abstracts transport details so the core enrollment code is shared between HTTPS and CoAP.


Multi-CA HA failover state machine

When [ha] is enabled, each CA in an [[ha.group]] transitions through four states. The HA controller runs periodic health checks and manages transitions automatically.

flowchart LR
    H["<b>Healthy</b><br/>operational<br/>receiving traffic"]:::server
    D["<b>Degraded</b><br/>checks failing<br/>still serving"]:::auth
    F["<b>Failed</b><br/>removed from<br/>routing pool"]:::infra
    R["<b>Recovery</b><br/>probing<br/>no traffic yet"]:::crypto

    H -- "health check<br/>fails" --> D
    D -- "check<br/>passes" --> H
    D -- "failure_threshold<br/>consecutive<br/>failures" --> F
    F -- "recovery_timeout<br/>elapsed" --> R
    R -- "sustained success<br/>(threshold<br/>checks pass)" --> H
    R -- "check fails<br/>during<br/>recovery" --> F

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef store  fill:#1f1a0a,stroke:#ca8a04,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0
    classDef infra  fill:#1f0a18,stroke:#db2777,color:#e2e8f0

State definitions:

  • Healthy – CA is operational. Health checks pass. Enrollment requests are routed to this CA normally.
  • Degraded – One or more health checks have failed but the threshold has not been reached. The CA continues to receive traffic. An alert is raised.
  • Failed – Consecutive failures have reached failure_threshold. The CA is removed from the routing pool. If the CA was the active node in an active-passive group, the next CA in ca_ids order is promoted.
  • Recovery – After recovery_timeout elapses, the HA controller begins probing the failed CA. It must pass failure_threshold consecutive checks before returning to Healthy. During recovery the CA does not receive enrollment traffic.

Failover strategies (set per [[ha.group]] or globally in [ha]):

StrategyBehavior
active-passiveFirst healthy CA in ca_ids order handles all requests. Failover promotes the next CA in order.
round-robinRequests are distributed across all healthy CAs in rotation.
weightedCAs are weighted by a configurable priority; higher-priority CAs receive more traffic.
latency-basedHealth check latency is tracked; requests are routed to the CA with the lowest observed latency.

The health check itself performs a lightweight signing operation (or, for Dogtag-backed CAs, a REST API ping) to verify that the CA can actually issue certificates. Network reachability alone is insufficient – a reachable HSM that has entered an error state must still be detected as unhealthy.


Authentication flow

OTP validation path

flowchart TD
    C["<b>Client</b><br/>HTTP Basic: entity_id : otp_value"]:::client

    LOOKUP["Look up entity_id<br/>in database"]:::store

    EXP{"Expired?<br/>expires_at > now"}:::auth
    USE{"Max uses<br/>reached?"}:::auth
    LOCK{"Locked out?<br/>failed_attempts<br/>≥ max_failures"}:::auth

    HASH["Hash provided OTP<br/>with argon2id"]:::crypto
    CMP{"Timing-safe comparison<br/>subtle::ConstantTimeEq"}:::crypto

    OK["<b>Success</b><br/>increment use count<br/>clear failure counter"]:::server
    FAIL["<b>Failure</b><br/>increment failed_attempts<br/>check lockout threshold"]:::infra

    C --> LOOKUP
    LOOKUP --> EXP
    EXP -- "not expired" --> USE
    EXP -- "expired" --> FAIL
    USE -- "within limit" --> LOCK
    USE -- "exhausted" --> FAIL
    LOCK -- "not locked" --> HASH
    LOCK -- "locked" --> FAIL
    HASH --> CMP
    CMP -- "match" --> OK
    CMP -- "mismatch" --> FAIL

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef store  fill:#1f1a0a,stroke:#ca8a04,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0
    classDef infra  fill:#1f0a18,stroke:#db2777,color:#e2e8f0

OTP values are never stored in plaintext. The argon2id hash is computed at token generation time and only the hash is persisted. The raw token is returned to the administrator exactly once in the generation response.

mTLS certificate chain validation

flowchart TD
    C["<b>Client</b><br/>TLS ClientHello<br/>+ Certificate"]:::client

    subgraph rustls_verify ["rustls verification"]
        SIG["Verify certificate<br/>signature is valid"]:::crypto
        EXP["Check certificate<br/>is not expired"]:::crypto
        CHAIN["Issuer chain terminates<br/>at configured trust anchor"]:::crypto
        KU["Key usage includes<br/>digitalSignature"]:::crypto
        EKU["Extended key usage<br/>includes clientAuth"]:::crypto
    end

    EXTRACT["<b>kipuka-est extracts</b><br/>Subject DN (audit + authz)<br/>Serial number (identity tracking)<br/>SAN entries (device identification)"]:::server

    C --> SIG
    SIG --> EXP
    EXP --> CHAIN
    CHAIN --> KU
    KU --> EKU
    EKU --> EXTRACT

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0

    style rustls_verify fill:none,stroke:#30363d,stroke-dasharray:5 5

GSSAPI / Kerberos

flowchart LR
    C["<b>Client</b><br/>Negotiate:<br/>SPNEGO token"]:::client
    CTX["Accept security<br/>context using<br/>server keytab"]:::auth
    PRINC["Extract<br/>authenticated<br/>principal<br/>user@REALM"]:::auth
    MAP["Map principal to<br/>certificate subject<br/>via principal_mapping<br/>or default_template"]:::server
    SUBJ["Mapped subject →<br/>certificate<br/>construction"]:::crypto

    C --> CTX --> PRINC --> MAP --> SUBJ

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0

Database schema overview

kipuka uses sqlx with compile-time checked queries. The schema is managed through sequential migrations in migrations/{sqlite,postgres,mariadb}/.

Core tables

otps – One-time password state.

ColumnTypeDescription
idINTEGER / SERIALPrimary key
entity_idTEXTClient identifier (e.g., device hostname)
otp_hashTEXTArgon2id hash of the OTP value
created_atTIMESTAMPToken generation time
expires_atTIMESTAMPToken expiration time
max_usesINTEGERMaximum allowed uses
use_countINTEGERCurrent use count
failed_attemptsINTEGERConsecutive failed validation attempts
last_failure_atTIMESTAMPTime of most recent failed attempt
locked_untilTIMESTAMPLockout expiration (NULL if not locked)

audit_log – Tamper-evident audit trail.

ColumnTypeDescription
idINTEGER / SERIALPrimary key
timestampTIMESTAMPEvent time (UTC)
event_typeTEXTEvent category (enroll, renew, reject, otp_create, etc.)
entity_idTEXTClient or device identifier
ca_idTEXTCA that processed the request
labelTEXTEST label (NULL for unlabeled requests)
auth_methodTEXTAuthentication method (mtls, otp, gssapi)
outcomeTEXTsuccess or failure
detailTEXTHuman-readable detail or error message
cert_fingerprintTEXTSHA-256 fingerprint of issued certificate (NULL on failure)
client_ipTEXTSource IP address

certs – Certificate inventory.

ColumnTypeDescription
idINTEGER / SERIALPrimary key
serial_numberTEXTCertificate serial (hex-encoded)
subject_dnTEXTSubject distinguished name
issuer_dnTEXTIssuer distinguished name
not_beforeTIMESTAMPValidity start
not_afterTIMESTAMPValidity end
fingerprintTEXTSHA-256 fingerprint
ca_idTEXTIssuing CA identifier
labelTEXTEST label used for issuance
entity_idTEXTAssociated entity (from OTP or mTLS subject)
pemTEXTFull PEM-encoded certificate (optional, controlled by config)

EST label routing

EST labels are the primary mechanism for multi-profile and multi-CA operation. When a request arrives at /.well-known/est/{label}/simpleenroll, kipuka resolves the label to a [[est.label]] configuration entry:

flowchart TD
    REQ["<b>Request URL</b><br/>/.well-known/est/<b>iot-devices</b>/simpleenroll"]:::client

    LABEL["<b>Label lookup</b><br/>name == iot-devices"]:::server

    CA["<b>CA lookup</b><br/>ca_id == iot-ca<br/>cert · key · chain<br/>validity_days · hsm_slot"]:::crypto

    HA{"CA in HA group<br/>and in Failed state?"}:::infra

    NEXT["Route to next healthy<br/>CA in group ca_ids"]:::server

    POLICY["<b>Policy enforcement</b><br/>allowed_key_types<br/>required_ext_key_usage<br/>require_san · subject_pattern<br/>max_validity_days"]:::auth

    ISSUE["<b>Certificate issuance</b><br/>using resolved CA key material<br/>and label policy"]:::crypto

    REQ --> LABEL
    LABEL -- "ca_id = iot-ca" --> CA
    CA --> HA
    HA -- "yes" --> NEXT
    NEXT --> POLICY
    HA -- "no" --> POLICY
    POLICY --> ISSUE

    classDef client fill:#1a2332,stroke:#3b82f6,color:#e2e8f0
    classDef server fill:#132218,stroke:#16a34a,color:#e2e8f0
    classDef crypto fill:#2d1a0a,stroke:#f0883e,color:#e2e8f0
    classDef store  fill:#1f1a0a,stroke:#ca8a04,color:#e2e8f0
    classDef auth   fill:#1a1025,stroke:#8b5cf6,color:#e2e8f0
    classDef infra  fill:#1f0a18,stroke:#db2777,color:#e2e8f0

Requests without a label segment (e.g., /.well-known/est/simpleenroll) use the first [[ca]] entry as the default CA with no additional label-level policy enforcement.

When HA is enabled, label routing is extended: the label’s ca_id is checked against [[ha.group]] memberships. If the CA belongs to an HA group and is in a Failed state, the request is transparently routed to the next healthy CA in the group’s ca_ids list. The label’s policy constraints (key types, subject pattern, etc.) are still enforced regardless of which CA in the group handles the request.