<!--
Full-page Markdown export (rendered HTML → GFM).
Source: https://neotoma.io/ru/aauth/cli-keys
Generated: 2026-04-27T12:48:52.722Z
-->
# AAuth CLI keys and hardware backends
How the Neotoma CLI generates AAuth keypairs, mints `aa-agent+jwt` agent tokens, and attaches a `cnf.attestation` envelope when the operator opts in to hardware-backed signing across macOS, Linux, Windows, and any host with a YubiKey 5 series device. This page is the client-side counterpart to [attestation](/aauth/attestation), which specifies how the server verifies the resulting envelope.
Implementation in `src/cli/aauth_signer.ts` plus optional native packages in `packages/aauth-mac-se/` (darwin), `packages/aauth-tpm2/` (linux), `packages/aauth-win-tbs/` (win32), and `packages/aauth-yubikey/` (cross-platform).
## Platform support matrix
After v0.10.x the `--hardware` flag is supported on every platform Neotoma ships an installer for. The CLI selects a backend per the ladder below; operators can pin a specific backend via `NEOTOMA_AAUTH_HARDWARE_BACKEND`.
- **darwin**, default `aauth-mac-se` (Secure Enclave); fallback `aauth-yubikey` when an external YubiKey is preferred.
- **linux**, default `aauth-tpm2` (TPM 2.0); fallback `aauth-yubikey` when no `/dev/tpmrm0`.
- **win32**, default `aauth-win-tbs` (TBS + NCrypt); fallback `aauth-yubikey` when no Microsoft Platform Crypto Provider.
- **any host with a YubiKey** , `aauth-yubikey`; pure-fallback path.
The wire format produced by each backend, and therefore the server-side verifier it lands in, is fixed:
- `apple-secure-enclave` → `apple-secure-enclave` → `verifyAppleSecureEnclaveAttestation`
- `tpm2` (linux) → `tpm2` → `verifyTpm2Attestation`
- `tbs` (win32) → `tpm2` → `verifyTpm2Attestation`
- `yubikey` (any) → `webauthn-packed` → `verifyWebauthnPackedAttestation`
Notably: Windows/TBS reuses the TPM 2.0 verifier unchanged even though the CLI talks to `NCrypt` rather than raw TPM commands; YubiKey reuses the WebAuthn-`packed` verifier because YubiKey PIV chains terminate at Yubico's PIV CA, not a TPM manufacturer chain.
◆
## Signer backends
`src/cli/aauth_signer.ts` supports five key-storage backends, recorded in `signer.json` as `backend`:
- **`software`** (default), private key at `~/.config/neotoma/aauth/private.jwk`; any platform; tier `software`, or `operator_attested` if allowlisted.
- **`apple-secure-enclave`** , macOS Secure Enclave keychain entry, referenced by `se_key_tag`; tier `hardware` when the JWT carries a verified `cnf.attestation`.
- **`tpm2`** , Linux TPM 2.0 persistent handle (`tpm2_handle`, default `0x81010000`) under the configured `tpm2_hierarchy`; tier `hardware` when the JWT carries a verified TPM 2.0 envelope.
- **`tbs`** , Windows TBS / NCrypt key under the Microsoft Platform Crypto Provider, referenced by `tbs_key_name` (default `neotoma-aauth-aik`); tier `hardware` when the JWT carries a verified TPM 2.0 envelope (TBS reuses the same server verifier).
- **`yubikey`** , YubiKey 5 series PIV slot 9c, referenced by `yubikey_serial` and `yubikey_pkcs11_path`; tier `hardware` when the JWT carries a verified WebAuthn-`packed` envelope rooted in the bundled Yubico PIV CA.
For SE-backed keys the on-disk `private.jwk` stores ONLY public material plus a `backend: "apple-secure-enclave"` discriminator, the private scalar never leaves the Enclave. Signing always goes through `@neotoma/aauth-mac-se`'s native `sign()` primitive.
◆
## On-disk schema (`signer.json`)
{
"sub": "cli@<hostname>",
"iss": "https://neotoma.cli.local",
"kid": "<jwk-thumbprint>",
"token\_ttl\_sec": 300,
"backend": "apple-secure-enclave",
"se\_key\_tag": "io.neotoma.aauth.cli.default"
}
`backend` and `se_key_tag` are optional and absent for legacy software keypairs (treated as `backend: "software"` for back-compat). Software keypairs continue to store a full private JWK in `private.jwk` with mode `0600`. SE-backed `private.jwk` files contain only public coordinates plus the `backend` field; reading them with `jose` produces a public-key import as expected.
◆
## Keygen flows
### `neotoma auth keygen` (software, default)
1. Generate a P-256 keypair via Web Crypto.
2. Write `private.jwk` (full JWK with `d`) at `0600`, `public.jwk` at `0644`, and `signer.json` with `backend: "software"`.
### `neotoma auth keygen --hardware` (darwin / Secure Enclave)
Available on darwin only and requires `@neotoma/aauth-mac-se` installed (ships as an optional dependency on macOS hosts).
1. Validate `alg === "ES256"` (Secure Enclave only supports P-256).
2. Probe `se.isSupported()`; refuse if the host has no usable Secure Enclave or the binding cannot load.
3. Call `se.generateKey({ tag: <se_key_tag> })`; the binding creates a fresh P-256 keypair pinned to the Enclave with biometric access policy and returns the public coordinates as a JWK.
4. Persist `private.jwk` (public material + `backend: "apple-secure-enclave"`), `public.jwk`, and `signer.json` with `backend` and `se_key_tag`.
### `neotoma auth keygen --hardware` (Linux / TPM 2.0)
Available on linux only and requires `@neotoma/aauth-tpm2`. Ships as an optional dependency on linux x64 / arm64.
1. Validate `process.platform === "linux"` and that the requested alg is `ES256` or `RS256`.
2. Probe `tpm2.isSupported()`; refuse if no usable `/dev/tpmrm0`, the resource manager is unreachable, or the binding cannot load.
3. Call `tpm2.generateKey({ hierarchy, alg })`; the binding creates a fresh AIK, persists it at the configured TPM handle (default `0x81010000`, override via `NEOTOMA_AAUTH_TPM2_HANDLE`), and returns the public coordinates as a JWK.
4. Persist `private.jwk` (public material + `backend: "tpm2"`), `public.jwk`, and `signer.json` with `backend`, `tpm2_handle`, and `tpm2_hierarchy`.
### `neotoma auth keygen --hardware` (Windows / TBS + NCrypt)
Available on win32 only and requires `@neotoma/aauth-win-tbs`. Ships as an optional dependency on Windows x64 / arm64. Although the wire format is WebAuthn-`tpm` (and the server-side TPM 2.0 verifier is reused unchanged), the CLI uses Trusted Platform Module Base Services (TBS) and Cryptography Next Generation (CNG / NCrypt) APIs rather than direct command-level TPM 2.0 access, because Windows does not expose a stable userspace TPM device.
1. Validate `process.platform === "win32"` and that the alg is `ES256` or `RS256`.
2. Probe `tbs.isSupported()`; refuse if no usable Microsoft Platform Crypto Provider, TBS service disabled, or the binding cannot load.
3. Call `tbs.generateKey({ provider, scope, alg, keyName })`; the binding calls `NCryptOpenStorageProvider` followed by `NCryptCreatePersistedKey` against the Microsoft Platform Crypto Provider with `NCRYPT_MACHINE_KEY_FLAG` toggled by `scope`. Finalises with `NCryptFinalizeKey` and returns the public coordinates as a JWK.
4. Persist `private.jwk` (public material + `backend: "tbs"`), `public.jwk`, and `signer.json` with `backend`, `tbs_provider`, `tbs_scope`, and `tbs_key_name`.
### `neotoma auth keygen --hardware --backend=yubikey` (cross-platform / YubiKey)
Available on darwin / linux / win32 and requires `@neotoma/aauth-yubikey` plus `libykcs11` (the Yubico PKCS#11 provider) plus a YubiKey 5 series device. The wire format is WebAuthn-`packed`, NOT WebAuthn-`tpm`: YubiKey PIV slot attestations chain to Yubico's PIV CA bundled in `config/aauth/yubico_piv_roots.pem`, so the server reuses the WebAuthn-`packed` verifier rather than the TPM 2.0 verifier.
1. Validate the alg is `ES256` (YubiKey PIV slot 9c only supports P-256 with stable cross-platform attestation).
2. Probe `yk.isSupported({ pkcs11Path? })`; refuse if `libykcs11` not loadable, no YubiKey detected, firmware too old (< 5.0.0), or the binding cannot load.
3. Prompt for the PIV PIN (interactive TTY) or honour `NEOTOMA_AAUTH_YUBIKEY_PIN`. Forwarded once via `C_Login` and NEVER persisted to `signer.json`.
4. Call `yk.generateKey({ slot: "9c", alg: "ES256", pin, serial })`; binding calls `C_GenerateKeyPair` against the YubiKey's PKCS#11 slot, fetches the per-slot PIV attestation cert and the F9 attestation intermediate via `C_GetAttributeValue`. The private scalar NEVER leaves the YubiKey.
5. Persist `private.jwk` (public material + `backend: "yubikey"`), `public.jwk`, and `signer.json` with `backend`, `yubikey_slot`, `yubikey_serial`, and `yubikey_pkcs11_path`. PIN is NEVER persisted to disk.
If a probe fails the command exits with a clear diagnostic and an actionable hint. Operators can re-run without `--hardware` to fall back to the software backend, or with `NEOTOMA_AAUTH_HARDWARE_BACKEND=auto` (the default) to let the CLI pick the best available backend per the ladder above.
◆
## JWT minting (`mintCliAgentTokenJwt`)
`mintCliAgentTokenJwt(config, options)` produces an `aa-agent+jwt` agent token with these standard claims:
- `iss`, `sub`, `iat`, `exp`, `kid`
- `cnf.jwk`, the public JWK (RFC 7800 confirmation key).
- `cnf.attestation` (optional), see [attestation](/aauth/attestation).
For `backend: "software"` the function delegates to `jose.SignJWT`. For `backend: "apple-secure-enclave"` it manually composes the JOSE header and payload, computes the SHA-256 digest of the signing input, calls `se.sign()` (the binding returns a DER-encoded ECDSA signature produced inside the Enclave), converts the DER signature to JOSE r||s, and concatenates the three parts. Bit-for-bit compatible with software-signed tokens; private scalar never crosses into the JS runtime.
◆
## HTTP message signing (`cliSignedFetch`)
Single entry point for authenticated CLI → Neotoma API calls. Dispatches on `config.backend`:
- `software`, hands `signingKey` to `@hellocoop/httpsig`'s `signedFetch`, which signs over the default RFC 9421 component set with the `aasig` label.
- `apple-secure-enclave`, routes through `seSignedFetch`. The helper assembles the same component set (`@method`, `@target-uri`, optional `content-type` / `content-digest`, `signature-key`), builds the RFC 9421 signature base, and calls SE-backed `seSignJoseEs256` to produce the `signature` header. The agent token is carried in the `signature-key` header verbatim, exactly like the software path.
Both paths produce an identical wire format from the server's perspective; only the signing primitive differs.
◆
## Generating attestation envelopes
Each backend has a dedicated envelope builder:
- `buildAppleAttestationEnvelope`, Refuses unless `backend === "apple-secure-enclave"`; computes the challenge as `SHA-256(iss || sub || iat)` via `buildAttestationChallenge`; calls `se.attest({ tag, challenge })`; packages the Apple-issued chain plus key-binding signature. Returns `null` on non-darwin / missing binding / unsupported Enclave; the caller MUST then proceed with a software-tier signed write rather than fabricating an envelope.
- `buildTpm2AttestationEnvelope`, Linux/TPM 2.0 counterpart in `src/cli/aauth_tpm2_attestation.ts`. `isTpm2BackendAvailable` probes without throwing; `computeAttestationChallenge` derives bit-for-bit the same value as the server; the helper calls `tpm2.attest({ handle, challenge, jkt })` and packages the AIK chain, `TPMS_ATTEST` quote, and `TPMT_PUBLIC`. Throws `Tpm2BackendUnavailableError` with a structured `reason` when the backend is unavailable.
- `buildTbsAttestationEnvelope`, Windows / TBS counterpart in `src/cli/aauth_tbs_attestation.ts`. Even though the underlying call is `NCryptCreateClaim` against the Microsoft Platform Crypto Provider rather than raw TPM 2.0 commands, the wire format is identical to the Linux TPM 2.0 envelope (the server reuses the same verifier). Throws `TbsBackendUnavailableError` with a structured reason when unavailable.
- `buildYubikeyAttestationEnvelope`, Cross-platform YubiKey counterpart in `src/cli/aauth_yubikey_attestation.ts`. Wire format is WebAuthn-`packed`, NOT WebAuthn-`tpm`. Helper packages the per-slot YubiKey attestation cert plus the F9 intermediate. The `aaguid` field is hoisted to the envelope top level for convenience; the server-side verifier extracts AAGUID from the leaf cert's `id-fido-gen-ce-aaguid` extension when present and falls back to this hoisted field when the cert lacks it. Throws `YubikeyBackendUnavailableError` with a structured reason when unavailable.
PIN handling for YubiKey: every call that does not pass `pin` explicitly relies on the binding's PIN resolution (in priority order: explicit `pin` argument → `NEOTOMA_AAUTH_YUBIKEY_PIN` env var → interactive TTY prompt). The helper NEVER caches the PIN across invocations and NEVER logs PIN values. Three failed attempts lock the YubiKey; recovery requires `ykman piv access change-pin --puk`.
◆
## `auth session` output
`describeConfiguredSigner()` extends the legacy summary with three new fields so operators can confirm which backend is in use:
- `backend`, `"software"`, `"apple-secure-enclave"`, `"tpm2"`, `"tbs"`, or `"yubikey"`.
- `se_key_tag` / `tpm2_handle` / `tbs_key_name` / `yubikey_serial` (when present).
- `hardware_supported` / `hardware_supported_reason` (only when the signer is hardware-backed; reflects the most recent `isSupported()` probe).
`neotoma auth session` renders these fields in its `signer:` block.
◆
## Operator environment
- `NEOTOMA_AAUTH_PRIVATE_JWK_PATH`, override JWK location for all backends (default `~/.config/neotoma/aauth/private.jwk`).
- `NEOTOMA_AAUTH_SE_KEY_TAG`, override the keychain tag on darwin (default `io.neotoma.aauth.cli.default`).
- `NEOTOMA_AAUTH_TPM2_HANDLE`, override the TPM 2.0 persistent handle on linux (default `0x81010000`).
- `NEOTOMA_AAUTH_TPM2_HIERARCHY`, override the TPM 2.0 hierarchy on linux (`owner` or `endorsement`; default `owner`).
- `NEOTOMA_AAUTH_WIN_TBS_PROVIDER` / `NEOTOMA_AAUTH_WIN_TBS_KEY_NAME` / `NEOTOMA_AAUTH_WIN_TBS_SCOPE`, override the NCrypt provider, key name, and scope (`user` or `machine`) on Windows.
- `NEOTOMA_AAUTH_YUBIKEY_PKCS11_PATH` / `NEOTOMA_AAUTH_YUBIKEY_SERIAL` / `NEOTOMA_AAUTH_YUBIKEY_PIN`, point at `libykcs11`, pin to a specific YubiKey by decimal serial, or inject the PIV PIN non-interactively.
- `NEOTOMA_AAUTH_HARDWARE_BACKEND`, pin the hardware backend selection (`auto`, `apple-secure-enclave`, `tpm2`, `tbs`, `yubikey`; default `auto`).
Server-side trust knobs (`NEOTOMA_AAUTH_ATTESTATION_CA_PATH`, `NEOTOMA_OPERATOR_ATTESTED_ISSUERS`, etc.) are documented on the [attestation](/aauth/attestation) page; the CLI does not consume them directly.
Back to [AAuth overview](/aauth). See also [AAuth spec](/aauth/spec), [attestation](/aauth/attestation), [integration](/aauth/integration).