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); fallbackaauth-yubikeywhen an external YubiKey is preferred. - linux, default
aauth-tpm2(TPM 2.0); fallbackaauth-yubikeywhen no/dev/tpmrm0. - win32, default
aauth-win-tbs(TBS + NCrypt); fallbackaauth-yubikeywhen 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→verifyAppleSecureEnclaveAttestationtpm2(linux) →tpm2→verifyTpm2Attestationtbs(win32) →tpm2→verifyTpm2Attestationyubikey(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; tiersoftware, oroperator_attestedif allowlisted.apple-secure-enclave, macOS Secure Enclave keychain entry, referenced byse_key_tag; tierhardwarewhen the JWT carries a verifiedcnf.attestation.tpm2, Linux TPM 2.0 persistent handle (tpm2_handle, default0x81010000) under the configuredtpm2_hierarchy; tierhardwarewhen the JWT carries a verified TPM 2.0 envelope.tbs, Windows TBS / NCrypt key under the Microsoft Platform Crypto Provider, referenced bytbs_key_name(defaultneotoma-aauth-aik); tierhardwarewhen the JWT carries a verified TPM 2.0 envelope (TBS reuses the same server verifier).yubikey, YubiKey 5 series PIV slot 9c, referenced byyubikey_serialandyubikey_pkcs11_path; tierhardwarewhen the JWT carries a verified WebAuthn-packedenvelope 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)
- Generate a P-256 keypair via Web Crypto.
- Write
private.jwk(full JWK withd) at0600,public.jwkat0644, andsigner.jsonwithbackend: "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).
- Validate
alg === "ES256"(Secure Enclave only supports P-256). - Probe
se.isSupported(); refuse if the host has no usable Secure Enclave or the binding cannot load. - 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. - Persist
private.jwk(public material +backend: "apple-secure-enclave"),public.jwk, andsigner.jsonwithbackendandse_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.
- Validate
process.platform === "linux"and that the requested alg isES256orRS256. - Probe
tpm2.isSupported(); refuse if no usable/dev/tpmrm0, the resource manager is unreachable, or the binding cannot load. - Call
tpm2.generateKey({ hierarchy, alg }); the binding creates a fresh AIK, persists it at the configured TPM handle (default0x81010000, override viaNEOTOMA_AAUTH_TPM2_HANDLE), and returns the public coordinates as a JWK. - Persist
private.jwk(public material +backend: "tpm2"),public.jwk, andsigner.jsonwithbackend,tpm2_handle, andtpm2_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.
- Validate
process.platform === "win32"and that the alg isES256orRS256. - Probe
tbs.isSupported(); refuse if no usable Microsoft Platform Crypto Provider, TBS service disabled, or the binding cannot load. - Call
tbs.generateKey({ provider, scope, alg, keyName }); the binding callsNCryptOpenStorageProviderfollowed byNCryptCreatePersistedKeyagainst the Microsoft Platform Crypto Provider withNCRYPT_MACHINE_KEY_FLAGtoggled byscope. Finalises withNCryptFinalizeKeyand returns the public coordinates as a JWK. - Persist
private.jwk(public material +backend: "tbs"),public.jwk, andsigner.jsonwithbackend,tbs_provider,tbs_scope, andtbs_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.
- Validate the alg is
ES256(YubiKey PIV slot 9c only supports P-256 with stable cross-platform attestation). - Probe
yk.isSupported({ pkcs11Path? }); refuse iflibykcs11not loadable, no YubiKey detected, firmware too old (< 5.0.0), or the binding cannot load. - Prompt for the PIV PIN (interactive TTY) or honour
NEOTOMA_AAUTH_YUBIKEY_PIN. Forwarded once viaC_Loginand NEVER persisted tosigner.json. - Call
yk.generateKey({ slot: "9c", alg: "ES256", pin, serial }); binding callsC_GenerateKeyPairagainst the YubiKey's PKCS#11 slot, fetches the per-slot PIV attestation cert and the F9 attestation intermediate viaC_GetAttributeValue. The private scalar NEVER leaves the YubiKey. - Persist
private.jwk(public material +backend: "yubikey"),public.jwk, andsigner.jsonwithbackend,yubikey_slot,yubikey_serial, andyubikey_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,kidcnf.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, handssigningKeyto@hellocoop/httpsig'ssignedFetch, which signs over the default RFC 9421 component set with theaasiglabel.apple-secure-enclave, routes throughseSignedFetch. The helper assembles the same component set (@method,@target-uri, optionalcontent-type/content-digest,signature-key), builds the RFC 9421 signature base, and calls SE-backedseSignJoseEs256to produce thesignatureheader. The agent token is carried in thesignature-keyheader 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 unlessbackend === "apple-secure-enclave"; computes the challenge asSHA-256(iss || sub || iat)viabuildAttestationChallenge; callsse.attest({ tag, challenge }); packages the Apple-issued chain plus key-binding signature. Returnsnullon 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 insrc/cli/aauth_tpm2_attestation.ts.isTpm2BackendAvailableprobes without throwing;computeAttestationChallengederives bit-for-bit the same value as the server; the helper callstpm2.attest({ handle, challenge, jkt })and packages the AIK chain,TPMS_ATTESTquote, andTPMT_PUBLIC. ThrowsTpm2BackendUnavailableErrorwith a structuredreasonwhen the backend is unavailable.buildTbsAttestationEnvelope, Windows / TBS counterpart insrc/cli/aauth_tbs_attestation.ts. Even though the underlying call isNCryptCreateClaimagainst 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). ThrowsTbsBackendUnavailableErrorwith a structured reason when unavailable.buildYubikeyAttestationEnvelope, Cross-platform YubiKey counterpart insrc/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. Theaaguidfield is hoisted to the envelope top level for convenience; the server-side verifier extracts AAGUID from the leaf cert'sid-fido-gen-ce-aaguidextension when present and falls back to this hoisted field when the cert lacks it. ThrowsYubikeyBackendUnavailableErrorwith 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 recentisSupported()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 (defaultio.neotoma.aauth.cli.default).NEOTOMA_AAUTH_TPM2_HANDLE, override the TPM 2.0 persistent handle on linux (default0x81010000).NEOTOMA_AAUTH_TPM2_HIERARCHY, override the TPM 2.0 hierarchy on linux (ownerorendorsement; defaultowner).NEOTOMA_AAUTH_WIN_TBS_PROVIDER/NEOTOMA_AAUTH_WIN_TBS_KEY_NAME/NEOTOMA_AAUTH_WIN_TBS_SCOPE, override the NCrypt provider, key name, and scope (userormachine) on Windows.NEOTOMA_AAUTH_YUBIKEY_PKCS11_PATH/NEOTOMA_AAUTH_YUBIKEY_SERIAL/NEOTOMA_AAUTH_YUBIKEY_PIN, point atlibykcs11, 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; defaultauto).
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.