# Security and enrollment

Provisioner talks to servers that have no credentials yet. It checks every value it accepts from a
node before using it, and signs every credential it returns with keys that the node cannot reach.

{{% notice style="note" title="Where it lives" %}}
`host`: `host.go` holds `validateJWT`, `validateCSR`, `generateServerJWT`, and `encryptPrivateKey`.
`rpc.go` holds `fetchJWT`, `fetchCSR`, and `fetchEd25519PubKey`. Key material paths come from
`config.Config`.
{{% /notice %}}

## Enrollment models

`features.pki` and `features.ed25519` select mutually exclusive models, and `config.Load` rejects a
configuration that sets both. `features.jwt` is independent, and enabling `features.ed25519` turns it
on implicitly.

| Feature | What Provisioner fetches | What it gives back |
|---------|--------------------------|--------------------|
| `jwt` | The node's `provisioning.jwt`. | The node already holds this token, so Provisioner returns none. |
| `pki` | A CSR generated by the node. | A signed certificate and CA from the helper. |
| `ed25519` | The node's ed25519 public key, with a signed nonce. | A server JWT signed by Provisioner. |

## Provisioning token verification

Runs when `features.jwt` is set. Setting `features.ed25519` sets it too, so this step also runs in
Organization Issuer deployments.

`validateJWT` (`host/host.go:405`) refuses to run without `jwt_verify_cert`. `os.Stat` decides whether
that value is a key file path or a hex ed25519 public key:

```go
if _, err = os.Stat(h.cfg.JWTVerifyCert); os.IsNotExist(err) {
	pk, err = hex.DecodeString(h.cfg.JWTVerifyCert)
	claims, err = tokens.ParseProvisioningToken(h.rawJWT, ed25519.PublicKey(pk))
} else {
	claims, err = tokens.ParseProvisioningTokenWithKeyfile(h.rawJWT, h.cfg.JWTVerifyCert)
}
```

A path that exists on disk is read as a key file. Anything else is decoded as a hex ed25519 public
key, the form of an Organization Issuer public key. `validateJWT` stores the parsed claims on
`h.JWT`, and `getConfig` sends them to the helper as the verified `jwt` field.

## The ed25519 challenge

Runs when `features.ed25519` is set. `features.pki` deployments skip it, and `config.Load` rejects a
configuration that sets both.

`fetchEd25519PubKey` (`host/rpc.go:228`) generates a nonce
with `choria.NewRequestID()`, sends it in the `gen25519` request, and verifies the node's signature
over it:

```go
if !ed25519.Verify(pk, []byte(h.nonce), sig) {
	err = fmt.Errorf("invalid nonce signature")
	return
}
```

Without the nonce, a node could present any public key, including one belonging to a different
machine, and receive a server JWT bound to that key. The signature proves the node holds the matching
seed. The call also records the directory that the node created the seed in, which becomes the SSL
directory in the generated configuration.

## Server token issuance

Runs when `features.ed25519` is set, immediately after the helper returns. It also needs
`jwt_signing_key`, and `server_jwt_validity` or its one year default.

`generateServerJWT` (`host/host.go:251`) builds the claims in increasing order of precedence:
built-in defaults, the configuration that the helper returned, and the helper's explicit
`server_claims`.

<dl class="cm-kv">
  <dt>org</dt><dd>Defaults to <code>choria</code>. Overridden by <code>server_claims.ou</code>.</dd>
  <dt>collectives</dt><dd>Defaults to <code>mcollective</code>. Becomes <code>choria</code> when the returned configuration sets <code>plugin.security.provider</code> to <code>choria</code>, then a comma-split of the <code>collectives</code> configuration key, then <code>server_claims.collectives</code>.</dd>
  <dt>validity</dt><dd>Defaults to <code>server_jwt_validity</code>, or one year. A <code>server_claims</code> expiry is converted with <code>time.Until</code>.</dd>
  <dt>permissions</dt><dd>Null unless <code>server_claims.permissions</code> is set.</dd>
  <dt>additional publish subjects</dt><dd>Empty unless <code>server_claims.additional_publish_subjects</code> is set.</dd>
</dl>

A computed validity of one hour or less is discarded and replaced with the configured default, which
covers both an expiry already in the past and a claim that failed to parse.

{{% notice style="warning" title="Signing files are re-read for every server" %}}
When `jwt_signing_token` is set, `generateServerJWT` reads the signing token and the seed file from
disk for every server. The code does this deliberately: "a bunch of redundant repeated reading happens
here of the same files but I prefer to do that so just updating the secrets will update the running
instance". Rotating the issuer credentials takes effect on the next server rather than on the next
restart. Provisioning a thousand servers therefore performs two thousand small file reads.
{{% /notice %}}

With a signing token present, the token must carry a `TrustChainSignature` claim, and
`claims.AddChainIssuerData` attaches the chain before signing. `tokens.SignTokenWithKeyFile` signs the
result, and the `configure` call sends it to the node as `server_jwt`.

## CSR checks

Runs when `features.pki` is set. `features.ed25519` deployments skip it, because no x509 certificate
is issued.

`validateCSR` (`host/host.go:435`) parses the PEM that the node sent. The common name must equal the
identity that Provisioner discovered, and no name on the request may match the denylist. `validateCSR`
checks the common name and every DNS SAN.

The default denylist blocks the certificate names that Choria reserves for privileged clients:

```yaml
cert_deny_list:
  - "\\.privileged.mcollective$"
  - "\\.privileged.choria$"
  - "\\.mcollective$"
  - "\\.choria$"
```

`matchAnyRegex` (`host/host.go:468`) treats a pattern wrapped in forward slashes as a bare regular
expression and strips them, then matches with `regexp.MatchString`. Matching is unanchored, so a
pattern without `^` or `$` matches anywhere in the name.

{{% notice style="warning" title="Replacing cert_deny_list replaces the defaults" %}}
A node in provisioning mode is unauthenticated, so `validateCSR` is the only check on the name it
receives, and that name can request a certificate with privileged access to the whole fleet. Setting
`cert_deny_list` replaces the defaults rather than adding to them, so a custom list must repeat these
four patterns.
{{% /notice %}}

## Private key protection in transit

Runs when the helper returns a `key`, which no feature flag controls. It also needs `features.jwt`,
because the server's ECDH public key arrives on the `jwt` reply.

Some certificate authorities generate the key pair themselves rather than signing a CSR. For those,
the helper can return a `key`, and it has to cross the network to a node that holds no credentials.
`encryptPrivateKey` (`host/host.go:354`) uses an ephemeral Diffie-Hellman exchange over the two RPC
calls that already happen.

<figure class="cm-diagram">
  <svg viewBox="0 0 760 330" role="img" aria-label="Sequence diagram. Provisioner sends a jwt request to the Choria Server. The server replies with its provisioning token and an ECDH public key. Provisioner then generates its own ECDH key pair, derives a shared secret, and encrypts the private key with AES-256. It sends the encrypted key and its own public key in the configure call, and the server derives the same secret to decrypt it.">
    <defs>
      <marker id="scah" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="var(--cm-accent)"/></marker>
    </defs>
    <!-- actors -->
    <rect class="cm-svg-box" x="40" y="20" width="180" height="52" rx="8"/>
    <text class="cm-svg-label" x="130" y="44" text-anchor="middle">Choria Server</text>
    <text class="cm-svg-sub" x="130" y="61" text-anchor="middle">makes an ECDH pair</text>
    <rect x="540" y="20" width="180" height="52" rx="8" fill="color-mix(in srgb, var(--cm-accent) 18%, transparent)" stroke="var(--cm-accent)" stroke-width="2"/>
    <text class="cm-svg-label" x="630" y="44" text-anchor="middle" style="fill:var(--cm-accent)">Provisioner</text>
    <text class="cm-svg-sub" x="630" y="61" text-anchor="middle">holds the helper key</text>
    <!-- lifelines -->
    <line x1="130" y1="72" x2="130" y2="300" stroke="var(--cm-faint)" stroke-width="1.5" stroke-dasharray="5 5"/>
    <line x1="630" y1="72" x2="630" y2="300" stroke="var(--cm-faint)" stroke-width="1.5" stroke-dasharray="5 5"/>
    <!-- messages -->
    <line x1="630" y1="105" x2="134" y2="105" stroke="var(--cm-accent)" stroke-width="2" marker-end="url(#scah)"/>
    <text class="cm-svg-sub" x="382" y="98" text-anchor="middle">choria_provision#jwt</text>
    <line x1="130" y1="142" x2="626" y2="142" stroke="var(--cm-accent)" stroke-width="2" marker-end="url(#scah)"/>
    <text class="cm-svg-sub" x="378" y="135" text-anchor="middle">provisioning.jwt + ecdh_public</text>
    <!-- provisioner work -->
    <rect class="cm-svg-box" x="430" y="168" width="290" height="76" rx="8"/>
    <text class="cm-svg-label" x="575" y="192" text-anchor="middle">choria.ECDHKeyPair()</text>
    <text class="cm-svg-sub" x="575" y="212" text-anchor="middle">choria.ECDHSharedSecret(priv, serverPub)</text>
    <text class="cm-svg-sub" x="575" y="230" text-anchor="middle">x509.EncryptPEMBlock, AES-256</text>
    <!-- final message -->
    <line x1="630" y1="272" x2="134" y2="272" stroke="var(--cm-accent)" stroke-width="2" marker-end="url(#scah)"/>
    <text class="cm-svg-sub" x="382" y="265" text-anchor="middle">configure: encrypted key + provisioner ecdh_public</text>
    <text class="cm-svg-sub" x="130" y="292" text-anchor="middle">derives the same secret</text>
  </svg>
  <figcaption>Each secret covers one key on one node, and neither side writes it to disk.</figcaption>
</figure>

The server's ECDH public key arrives on the `jwt` reply as `EcdhPublic`, so key encryption requires
`features.jwt`. Without it `encryptPrivateKey` fails with "private key received from helper but server
did not start Diffie-Hellman exchange". `encryptPrivateKey` stores Provisioner's own public key on
`h.provisionPubKey`, and the `configure` call sends it as `EcdhPublic`, so the node can derive the
same secret and decrypt the PEM block.

`x509.EncryptPEMBlock` is deprecated in the standard library and the call carries a
`//lint:ignore SA1019 there is no alternative` comment. The format is fixed by what Choria Server can
decrypt.

{{% notice style="tip" title="Next" %}}
[RPC, retries, and upgrades]({{% relref "rpc-and-upgrades" %}}) covers the wrapper that every one of
these calls runs through.
{{% /notice %}}
