NURLNURL registrynurl-lang.org →

← pki-server

pki-server 0.3.0 API

service.nu

pki-server/src/service.nu — HTTP routes & request handlers.

API

: ~ s g_ca_cert_path ./certs/ca.crt``

: ~ s g_ca_key_path ./certs/ca.key``

: ~ s g_crl_file_path ./certs/ca.crl``

: ~ s g_index_file_path ./certs/index.txt``

: ~ s g_initial_certs_dir ./certs/initial``

: ~ s g_device_certs_dir ./certs/certificates``

: ~ s g_device_init_key ``

: ~ s g_management_key ``

: ~ s g_ca_cn Private PKI CA``

: ~ i g_ca_alg 0

: ~ i g_ca_handle 0

@ pki_service_init s ca_cert s ca_key s crl_file s index_file s initial_dir s device_dir s init_key s mgmt_key s ca_cn i alg → b

@ pki_service_alg → i

@ _resp_json i status String json_str → HttpResponse

@ _resp_err_json i status s msg → HttpResponse

@ _resp_html i status String html → HttpResponse

HTML responses carry the policy that makes the escaping in ui.nu a belt-and-braces measure rather than the only one: no inline script may run, nothing may be loaded cross-origin, and the page may not be framed. The UI's one script lives at /js/app.js precisely so script-src 'self' can hold.

@ _wants_json HttpRequest req → b

Check if request prefers JSON (via header or content-type)

@ _device_path s dir s id s ext → String

<dir>/<id>/<id>.<ext> — the one shape this service builds. id must already be sanitised.

@ _form_get ( Vec QueryPair ) pairs s name → String

Pull a field out of an application/x-www-form-urlencoded body.

@ _json_str_field Json obj s name → String

@ handle_health HttpRequest req Params p → HttpResponse

GET /health

@ handle_ca_cert HttpRequest req Params p → HttpResponse

GET /ca-cert

@ handle_crl HttpRequest req Params p → HttpResponse

GET /crl

@ handle_init HttpRequest req Params p → HttpResponse

POST /init

@ _initial_cert_matches s stored_path s submitted_pem → b

Compare a submitted PEM with the enrollment certificate on disk. Both are normalised to DER first, so re-wrapped armor or line endings do not change the answer.

@ _initial_cert_revoked s stored_path → b

Has this device's current enrollment certificate been revoked? The serial is read back out of the stored certificate and checked against index.txt, so revoking by serial alone — with no CN to invalidate a file by — still locks the device out.

@ handle_renew_initial_cert HttpRequest req Params p → HttpResponse

POST /renew_initial_cert

@ handle_request_cert_get HttpRequest req Params p → HttpResponse

GET /request-cert

@ _cert_error b is_json i status s msg → HttpResponse

@ handle_request_cert_post HttpRequest req Params p → HttpResponse

POST /request-cert

@ handle_request_csr_post HttpRequest req Params p → HttpResponse

POST /request-csr — Issue certificate from a client-provided PKCS#10 CSR. Client private key never crosses the wire (Zero Trust PKI).

@ handle_revoke_get HttpRequest req Params p → HttpResponse

GET /revoke

@ _revoke_error b is_json i status s msg → HttpResponse

@ handle_revoke_post HttpRequest req Params p → HttpResponse

POST /revoke

@ _cert_issued_by_ca * PkiCa ca s cert_pem → b

Signature-only check: was this certificate signed by our CA key? No validity-window test, because revoking an already-expired certificate is legitimate.

@ handle_index HttpRequest req Params p → HttpResponse

GET /

@ handle_api_docs HttpRequest req Params p → HttpResponse

GET /api

@ handle_style_css HttpRequest req Params p → HttpResponse

GET /css/style.css

@ handle_app_js HttpRequest req Params p → HttpResponse

GET /js/app.js

@ handle_favicon HttpRequest req Params p → HttpResponse

GET /favicon.ico

@ pki_build_app → *HttpApp


auth.nu

pki-server/src/auth.nu — API Key & device key authentication helpers.

Every comparison here is between a client-supplied string and a server secret, so all of them go through std/subtle.nu's constant_time_eq. string_eq and nurl_str_cmp stop at the first differing byte, which turns each request into a measurement of how many leading bytes were right — enough to recover a key byte-by-byte over a few thousand requests.

The empty key is treated as "no key configured" and DENIES rather than admits: main.nu never leaves one empty (it mints a random key when none is given), so reaching here with one means the process is misconfigured, and open-by-default is the wrong answer to that.

API

@ auth_check_api_key HttpRequest req s management_key → b

Check if request carries a valid Management API key.

@ auth_check_api_key_value s provided_key s management_key → b

Check a key supplied in a request body (form field or JSON member) against the management key.

@ auth_check_device_key s provided_key s expected_key → b

Check if provided initialization key matches configured key.


pki.nu

pki-server/src/pki.nu — Pure-NURL PKI Engine & CA Operations.

The CA signs with one of two algorithm families, chosen at CA creation and then fixed for the life of the key:

PKI_ALG_P256 ecdsa-with-SHA256 over prime256v1 (classical) PKI_ALG_MLDSA44 \ PKI_ALG_MLDSA65 > ML-DSA (FIPS 204) (post-quantum) PKI_ALG_MLDSA87 /

Everything downstream — device keys, the CRL signature, chain verification — follows the CA's choice, so a PQ deployment has no classical signature anywhere in the trust path.

API

& c @ nurl_rand_fill *u buf i n → i

@ pki_alg_p256 → i

@ pki_alg_from_name s name → i

Map a --algorithm name to the internal code. Returns -1 for an unknown name so the caller can reject it rather than silently fall back to the classical default.

@ pki_alg_name i alg → s

@ pki_alg_display i alg → s

Human-facing description for the startup banner and the web UI.

@ pki_alg_is_pq i alg → b

: PkiCert

: PkiCert {
    String cert_pem
    String key_pem
    String serial_hex
    String expires_iso
}

@ pki_cert_free PkiCert c → v

: PkiCertInfo

: PkiCertInfo {
    String serial_hex
    String cn
    b ok
}

@ pki_cert_info_free PkiCertInfo i → v

: PkiCa

: PkiCa {
    i alg
    ( Vec u ) scalar  // P-256 private scalar
    ( Vec u ) pubkey  // P-256 public point
    ( Vec u ) ml_sk  // ML-DSA private key
    ( Vec u ) ml_pk  // ML-DSA public key
    String cert_pem
    String key_pem
    String cn
}

alg selects which key pair below is live: 0 uses scalar/pubkey, 44/65/87 use ml_sk/ml_pk. The unused pair stays empty rather than being absent, so pki_ca_free has one shape to release.

@ pki_ca_new → *PkiCa

@ pki_ca_free * PkiCa ca → v

@ pki_ca_public * PkiCa ca → ( Vec u )

The public key as it appears in the SubjectPublicKeyInfo BIT STRING — what the key identifier is computed over, and what verification needs.

@ _pki_rand_bytes i n → ( Vec u )

@ _pki_scalar_ok ( Vec u ) d → b

@ _pki_nibble i n → i

@ _pki_bytes_to_hex ( Vec u ) b → String

@ pki_normalise_serial s raw → String

A certificate serial as it may arrive from a client: lowercase hex, an even number of digits, at most 40 bytes (RFC 5280 caps a serial at 20 octets; twice that leaves room for encoders that pad). Anything else is rejected outright — the value is echoed into HTML, appended to index.txt and re-encoded as a DER INTEGER, and each of those is a place a stray <, tab or newline does damage.

@ pki_sanitize_id s raw → String

Reduce an identifier to [A-Za-z0-9._-] and refuse the pure-dot forms. Every path this package builds under --initial-dir / --certs-dir goes through here first: the CN carried by a submitted certificate is attacker-chosen, and <dir>/<cn>/<cn>.crt with cn = "../.." writes outside the tree.

@ _pki_pow256 i k → i

@ _pki_tlv i tag ( Vec u ) content → ( Vec u )

@ _pki_int ( Vec u ) mag → ( Vec u )

@ _pki_int1 i v → ( Vec u )

@ _pki_int_hex s hex → ( Vec u )

@ _pki_oid s hex → ( Vec u )

@ _pki_bitstring ( Vec u ) content → ( Vec u )

@ _pki_bool_true → ( Vec u )

@ _pki_push2 String st i v → v

@ pki_utctime_str i unix → String

UTCTime body, YYMMDDHHMMSSZ — also the on-disk form in index.txt, so a revocation date survives a restart instead of being restamped.

@ pki_utctime_parse s raw → i

Inverse of pki_utctime_str. Returns -1 when the field is not a well-formed UTCTime, so a hand-edited index.txt cannot inject a bogus revocationDate into the CRL.

@ _pki_utctime i unix → ( Vec u )

@ pki_iso_timestamp i unix → String

@ _pki_name s cn → ( Vec u )

@ _pki_alg_id i alg → ( Vec u )

AlgorithmIdentifier for the CA's signature algorithm. ML-DSA carries no parameters — the OID fixes the parameter set — while ecdsa-with- SHA256 omits them too (RFC 5758 §3.2).

@ _pki_spki i alg ( Vec u ) pubk → ( Vec u )

@ _pki_ext s oid_hex b critical ( Vec u ) value → ( Vec u )

Extension ::= SEQ { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }

@ _pki_key_id ( Vec u ) pubk → ( Vec u )

RFC 7093 §2 method 1: leftmost 160 bits of SHA-256 over the raw public key. RFC 5280's own method 1 names SHA-1; the truncated SHA-256 form is the sanctioned modern replacement and keeps this package free of a SHA-1 dependency.

@ _pki_keyusage b is_ca → ( Vec u )

@ _pki_eku → ( Vec u )

@ _pki_extensions s cn b is_ca ( Vec u ) subject_pub ( Vec u ) issuer_pub → ( Vec u )

The full extension block. A CA cert gets basicConstraints/keyUsage marked critical and a subjectKeyIdentifier; a leaf additionally gets extendedKeyUsage, the dNSName SAN and an authorityKeyIdentifier pointing at the issuer. RFC 5280 §4.2.1.9 and §4.2.1.3 require the first two on any cert that signs; the identifiers are what lets a verifier build a chain without trial-and-error.

@ _pki_sig_der ( Vec u ) rs → ( Vec u )

@ _pki_pem s label ( Vec u ) der → String

@ _pki_sign * PkiCa ca ( Vec u ) tbs → ( Vec u )

The signatureValue bytes exactly as they go into the outer BIT STRING: a DER ECDSA-Sig-Value for P-256, the raw FIPS 204 signature for ML-DSA (which specifies no wrapper, and hashes internally — so there is no digest step on that path).

@ _pki_verify_sig i alg ( Vec u ) pubk ( Vec u ) tbs ( Vec u ) sig → b

Verify a TBS blob against an issuer public key. alg is the CA's algorithm code; sig is the BIT STRING content as x509_parse hands it back.

@ _pki_tbs * PkiCa ca s issuer_cn s subject_cn ( Vec u ) sub_pub ( Vec u ) serial i not_before i not_after b is_ca → ( Vec u )

Assemble a TBSCertificate. sub_pub is the subject's raw public key (65-byte EC point or ML-DSA pk); the CA supplies the issuer name, the issuer key identifier and the signature algorithm.

@ _pki_wrap_cert * PkiCa ca ( Vec u ) tbs → ( Vec u )

Certificate ::= SEQ { tbsCertificate, signatureAlgorithm, signature }

@ _pki_priv_pem i alg ( Vec u ) sk ( Vec u ) pubk → String

SEC1 / RFC 5915 ECPrivateKey, or PKCS#8 OneAsymmetricKey for ML-DSA.

@ pki_generate_ca s cn i validity_days i alg → *PkiCa

@ _pki_ca_from_pem s cert_pem s key_pem s ca_cn → *PkiCa

Rebuild a CA handle from a stored cert + key pair. Returns 0 when the pair does not parse, does not agree on the algorithm, or when the private key does not match the certificate's public key — a mismatch would otherwise produce certificates nothing can verify.

@ pki_load_or_create_ca s ca_cert_path s ca_key_path s ca_cn i alg → *PkiCa

Load the CA, or mint one when either half is missing. An existing CA that fails to load returns 0 instead of being silently replaced: overwriting a live CA key would invalidate every certificate ever issued under it, so that has to be an operator decision.

@ pki_issue_device_cert * PkiCa ca s device_id i validity_days → PkiCert

@ _pki_csr_mldsa_level Csr csr → i

Rebuild the requester's SubjectPublicKeyInfo from the parsed CSR. csr.nu's key_alg codes: 1 RSA, 2 EC P-256, 3 Ed25519, 4 ML-DSA.

Csr records key_alg = 4 for all three ML-DSA parameter sets without keeping which one it was, but a PKCS#10 request is self-signed by definition — csr_verify checks the signature against this very key — so sig_alg (8/9/10) names the level unambiguously for any CSR that got this far.

@ _pki_spki_from_csr Csr csr → ( Vec u )

@ pki_issue_cert_from_csr * PkiCa ca s csr_pem i validity_days → !PkiCert String

Issue an X.509 certificate from a verified PKCS#10 CSR. The subject key comes from the CSR untouched — the requester's algorithm need not match the CA's — while the signature is always the CA's.

@ pki_verify_cert * PkiCa ca s cert_pem s expected_cn → b

Full check of a leaf against this CA: parses, enforces the validity window, matches the expected CN against the SANs and verifies the issuer signature with the CA's algorithm.

@ pki_extract_cert_info s cert_pem → PkiCertInfo

@ pki_generate_crl * PkiCa ca ( Vec String ) revoked_serials ( Vec i ) revoked_times → String

: PkiRevoked

: PkiRevoked {
    ( Vec String ) serials
    ( Vec i ) times
}

One parsed R line of the OpenSSL-style index.txt.

@ pki_revoked_free PkiRevoked r → v

@ pki_read_revoked s index_file_path → PkiRevoked

Read the revoked set out of index.txt. Fields are R <notAfter> <revocationDate> <serial> <filename> <subject>, both dates in UTCTime — so the original revocation date is what goes back into the regenerated CRL. Lines whose serial or date does not parse are dropped rather than propagated.

@ pki_is_revoked s index_file_path s serial_hex → b

Is this serial on the revocation list? Used to refuse an operational certificate to a device whose enrollment certificate was revoked — the file-level invalidation alone is not a check, it is a side effect.

@ pki_record_revocation s index_file_path s crl_file_path * PkiCa ca s serial_hex s cn → String

serial_hex and cn must already have been through pki_normalise_serial / pki_sanitize_id — this appends them to a tab-separated file, where a raw tab or newline would forge records.

@ pki_load_crl s crl_file_path * PkiCa ca s index_file_path → String

@ pki_invalidate_initial_cert s initial_dir s device_id → b

Overwrite a device's stored enrollment certificate so the DER-equality gate on /request-cert and /renew_initial_cert can never match again. device_id is sanitised here, at the one place that turns it into a path — a CN lifted out of a submitted certificate is attacker-chosen.


ui.nu

pki-server/src/ui.nu — Server-rendered Web Interface for PKI management.

API

@ ui_html_escape s raw → String

Escape a value for interpolation into HTML text or a quoted attribute. Everything this module renders that did not come from a string literal in this file goes through here — a certificate serial and an error message both arrive from the request body, and <script> in either one used to reach the browser verbatim.

The five characters are the OWASP text-and-attribute set: escaping both quote forms is what makes the same function safe in an attribute context, not only between tags.

@ _ui_push_esc String out s raw → v

@ _ui_wrap s title String body s active_nav → String

Base layout wrapper

@ ui_render_index → String

@ ui_render_request_cert s error_msg → String

@ ui_render_cert_result s device_id s cert s key s ca_cert s expires → String

@ ui_render_revoke_cert s error_msg → String

@ ui_render_revoke_result s message s serial s revocation_time s crl → String

@ ui_render_api_docs s alg_display → String

@ ui_default_css → String

@ ui_app_js → String

The page's only script, served from /js/app.js so the Content-Security- Policy can say script-src 'self' and mean it. Inline onclick= handlers would force 'unsafe-inline', which is the same as having no script policy at all.


main.nu

pki-server/src/main.nu — Pure-NURL Private PKI Server.

API

@ _env_or s name s default_val → String

Helper: env var or default

@ _resolve ArgParser ap s flag s env_name s fallback → String

CLI flag beats environment variable beats built-in default.

@ _is_placeholder_key String k → b

The two placeholder secrets earlier releases shipped as defaults. A deployment that never overrode them was authenticating every caller against a string printed in the README, so they are treated as "no key given" rather than as a key.

@ _generate_key s label → String

Mint a 192-bit key and tell the operator what it is — once, on stderr. Refusing to start would be the other defensible answer, but it breaks docker run with no volume; a key nobody can guess, printed where the logs will keep it, is secure without being unusable.

@ main → i