> Markdown version of https://agledger.ai/docs/install/fips/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# FIPS 140 hosts

AGLedger signs its chain with Ed25519 by default. A host running OpenSSL in FIPS mode cannot
compute Ed25519: the provider carries no EdDSA, and signing fails with
`error:0308010C:digital envelope routines::unsupported`.

For those hosts AGLedger supports a **single-Server ES256 configuration**. Every signature the
Server makes - chain entries, signed statements, and webhook signatures - uses ECDSA P-256 with
SHA-256 instead. The FIPS provider ships in the runtime image; no FIPS kernel and no
`openssl fipsinstall` step are required.

## Install

```
$ ./scripts/install.sh --fips
```

`--fips` does two things. It implies ES256, so the installer generates a P-256 vault key and writes
`AGLEDGER_ALLOW_NON_DEFAULT_SIGNING_ALG=true` into `compose/.env`. That acknowledgment is required:
the Server refuses to boot on a non-default algorithm without it, so nobody reaches this
configuration by accident. It also records `AGLEDGER_FIPS=true`, which adds
`docker-compose.fips.yml` to every compose command the scripts run from then on, including
`upgrade.sh`.

That last part is why the flag exists rather than a documented `-f` list. The overlay mounts
`compose/openssl-fips.cnf` and points `OPENSSL_CONF` at it. If a later upgrade recreated the
containers without it, the host would quietly stop being FIPS and nothing would say so, because an
ES256 key signs perfectly well with or without the provider active.

Confirm the provider is active:

```
$ docker compose exec -e NODE_OPTIONS= agledger-api /nodejs/bin/node \
    -e "console.log(require('crypto').getFips())"
```

`1` means active. With an ES256 key the install then runs normally: records notarize, gates
evaluate, and a chain scan reports the chain healthy.

## What this configuration gives up

The Server enforces each of these rather than degrading quietly.

**Federation is not available.** The federation transport signs with Ed25519 by design. A Server
configured with both ES256 and federation keys refuses to boot and names the conflict. Run
single-Server.

**Ed25519-pinned surfaces refuse explicitly.** A webhook subscription requesting
`signingAlg: "ed25519"` returns 422 naming what this Server can use (`ecdsa-p256-sha256`, `hmac`).
Nothing silently downgrades to an algorithm you did not ask for.

**Your license needs an ES256 reissue.** Licenses are Ed25519-signed by default, and this host
cannot verify one, so a valid license reads as unverifiable and the Server raises its license
banner. The banner carries the remedy rather than calling the file corrupt: request an ES256-signed
reissue from AGLedger (no charge, same terms) and replace the configured key.

**Agent-signature surfaces are unavailable.** Ephemeral certificate issuance and the
`X-Agent-Signature` hook bind Ed25519 agent keys, so both refuse on a FIPS host with the real
reason instead of a false "proof-of-possession invalid". API-key authentication is unaffected and
is the path here; the record spine is unaffected.

**Your chain consumers need a current verifier.** Anyone verifying offline needs
[`@agledger/verify`](https://www.npmjs.com/package/@agledger/verify) 1.4.0 or later. The algorithm
support lives in its `@agledger/verify-core` dependency rather than in `verify` itself; 1.4.0 is the
`verify` release that resolves a core carrying ES256 under every install shape, including a consumer
with an older core pinned at the top level. Quote the `verify` version, because that is the package
people install. Your Server publishes the exact floor per key:

```
$ curl -s http://localhost:3001/v1/verification-keys \
    | jq '.data[] | {keyId, algorithm, minVerifierVersion}'
```

The people who have to upgrade are usually not the people who ran the installer. Tell them before
they run one, because a verifier that cannot compute the algorithm does not reliably say so: builds
from `verify-core` 1.1.0 on report `CHAIN_UNSUPPORTED_ALGORITHM` and name the fix, but older builds
have no such code path and report a signature failure instead, which reads as tampering on an
intact chain.

## Switching an install that already has a chain

Do not reinstall. Entries already written must keep verifying, and reinstalling would abandon them.
Rotate instead.

1. Generate a P-256 key on the Server host:

   ```
   $ docker compose run --rm agledger-api \
       dist/scripts/generate-signing-key.js --algorithm es256
   ```

2. Put the new `VAULT_SIGNING_KEY` and `AGLEDGER_ALLOW_NON_DEFAULT_SIGNING_ALG=true` in
   `compose/.env`. The acknowledgment is required here for the same reason it is on a fresh
   `--fips` install: the Server refuses to boot on a non-default algorithm without it.

3. Restart. This **stages** the key: on boot the Server reconciles the registry to the configured
   `VAULT_SIGNING_KEY`, activating it beside the Ed25519 key that is already active. Nothing is
   retired yet, and the install signs with both until you close the old key's window.

   ```
   $ docker compose up -d --force-recreate
   ```

4. Turn the FIPS provider on. Staging an ES256 key does not by itself run the Server under the FIPS
   provider; that needs `AGLEDGER_FIPS=true` and the `docker-compose.fips.yml` overlay, and
   `--fips` refuses to add them over an existing Ed25519 key. Re-run the installer now that the
   active key is ES256, then confirm the provider exactly as on a fresh install:

   ```
   $ ./scripts/install.sh --fips
   $ docker compose exec -e NODE_OPTIONS= agledger-api /nodejs/bin/node \
       -e "console.log(require('crypto').getFips())"
   ```

5. Retire the Ed25519 key, once every api and worker process has rolled onto the new one. Read
   `GET /health` on each process directly, not through a load balancer, and check `signingKey.keyId`
   reports the ES256 key everywhere; `lastSignedAt` is not the check, because it reads null on an
   idle process or one that has only signed webhooks or certificates. Then:

   ```
   $ curl -X POST -H "Authorization: Bearer $AGLEDGER_PLATFORM_KEY" \
       "$AGLEDGER_API_URL/v1/admin/vault/signing-keys/<old-ed25519-key-id>/retire"
   ```

   You end up with an active P-256 key beside the retired Ed25519 one, each carrying the exact
   instants it was active, which is what lets a verifier check every entry against the key that
   actually signed it. Nothing is re-signed and no history is abandoned. The routine version of
   these two steps is in [day-2 operations](/docs/operations/day-2/#signing-key-rotation); nothing
   about them changes for an algorithm change.

**Read the pre-rotation entries carefully, though.** Their signatures are Ed25519, and this host
cannot compute Ed25519 - that is why you rotated. An on-host chain scan therefore reports those
entries `unsupported_algorithm` rather than verifying them. That is a host capability gap, not a
tamper signal: the class is deliberately separate from `signature_invalid`, and the same entries
verify normally with the offline verifiers on any non-FIPS machine. Entries written after the
rotation verify on-host as usual.

A scan across a mixed history reads like this, with every pre-rotation entry counted broken:

```json
{"recordsScanned": 4, "verified": 0, "broken": 4, "signatureErrors": 4, "healthy": false,
 "brokenRecords": [{"recordId": "019fe8a5-...", "brokenAt": 1, "reason": "unsupported_algorithm"}]}
```

The same database dumped and checked off the FIPS host resolves each entry against whichever key
signed it and reports it clean; the [audit runbook](/docs/operations/audit/) has that handoff.
Decide up front where your pre-switch history gets verified, and tell whoever watches chain health
what to expect: `healthy: false` on an intact chain is the kind of alert that gets escalated as an
incident.

If an on-host clean scan over the whole history matters to you more than the existing entries do,
a fresh install signing ES256 from its first entry is the configuration that gives you that. Rotate
when the history is worth keeping; install fresh when it is not.

Re-running `install.sh` with `AGLEDGER_SIGNING_ALGORITHM` set does **not** change the algorithm of
an existing install: the signing key is generated only alongside the other secrets, so a run that
finds an existing `.env` keeps the key it already has. The installer says so rather than reporting
a success that did not happen.

## What is claimed

AGLedger runs correctly with a FIPS provider active and signs with an approved algorithm. That is a
statement about how the software is configured and what it calls, not a FIPS 140-2 or 140-3
validation certificate for AGLedger itself, and not a FedRAMP or GovCloud authorization. The
validated module is the one your platform supplies.

Ed25519 is itself FIPS-approved under [FIPS 186-5](https://csrc.nist.gov/pubs/fips/186-5/final), and
validated modules implementing it exist. The reason it is unavailable here is the vintage of the
provider shipped with the runtime base image, which predates that standard, not any property of the
algorithm.

Every release blocks on a FIPS boot gate that runs this exact configuration on `linux/amd64`,
asserting both that the ES256 serve path works and that a federation-configured Server refuses to
start under it. The general boot gate (migrations, `/health`, `/llms.txt`, `/v1/verification-keys`)
runs on both `linux/amd64` and `linux/arm64`; the FIPS-provider assertions run on `linux/amd64`
only. The `arm64` image is built from the same sources and is not exercised under the FIPS provider
by that gate.

## Sources

- [FIPS 186-5: Digital Signature Standard](https://csrc.nist.gov/pubs/fips/186-5/final) - NIST, the standard that approves EdDSA
- [FIPS 140-3 validated modules](https://csrc.nist.gov/projects/cryptographic-module-validation-program/validated-modules) - NIST CMVP search
- [RFC 6979: Deterministic ECDSA](https://www.rfc-editor.org/rfc/rfc6979) - IETF
