Neotoma

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, 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-enclaveapple-secure-enclaveverifyAppleSecureEnclaveAttestation
  • tpm2 (linux) → tpm2verifyTpm2Attestation
  • tbs (win32) → tpm2verifyTpm2Attestation
  • yubikey (any) → webauthn-packedverifyWebauthnPackedAttestation

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.

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 page; the CLI does not consume them directly.

Back to AAuth overview. See also AAuth spec, attestation, integration.