The experiment: a hardware-attested Claude connector in a bank's federation
The question
Can a bank issue a connector for Claude whose identity is a hardware key, bind a person to it with a passkey, let it authenticate with a Client Attestation (attest_jwt_client_auth), and onboard it into the bank's OpenID Federation - so that a different member of that federation, one that has never met the connector, trusts it through the federation alone?
Yes. It runs end to end against a real PingFederate 13.1.3 carrying the pf-agentic-identity modules, a go-oidfed Trust Anchor, and a partner that holds nothing but the anchor's pinned keys. The federation also carries what the agent is for: the bank vouches for its mission and caps it, each mandate states a purpose, and one federation-aware PDP holds the agent to both - at the bank before the customer is asked, and at the partner on every call (MISSION.md). The automated run passes with software keys and with Secure Enclave keys; the YubiKey path is built and tested against a fake PIV card and waits on a hardware run; App Attest turned out not to be offered for macOS apps at all.
What ran, and what proves it
| Step | Proven by | Result |
|---|---|---|
| The connector is a local stdio MCP server with nine tools | npm run e2e drives it as Claude does, through the MCP SDK client |
pass |
A passkey sign-in binds the person to the keys: the OIDC nonce is SHA-256(challenge ‖ inst-jkt ‖ fed-jkt) |
a software WebAuthn authenticator against the IdP's real verification; the attester recomputes the nonce | pass |
The bank attests the instance key (cnf.jwk) and PingFederate authenticates the connector with it at PAR and at the token endpoint, PoP mode, DPoP with a separate key |
e2e, and the PAR spike (13/13 with Secure Enclave keys) | pass |
| The agent signs its own Entity Configuration with its hardware federation key; the bank hosts and vouches for it; it earns the bank's Trust Mark | e2e resolves agent → bank → anchor with pinned anchor keys | pass |
Tokens are DPoP-bound, sub is the customer, act.sub is the agent's federation entity |
e2e, both audiences | pass |
A partner accepts the agent through the federation alone: bank's AS keys from its chain-validated metadata, the agent's chain, the Trust Mark and its status, client_id = the agent's software_id |
e2e partner_get_offers and /whoami |
pass |
| The agent's mission travels in the federation: it declares its RAR types and DPV purposes; the bank vouches for it in its own words and caps both; the anchor adds the framework's recognised purposes | e2e: an over-claiming agent resolves to the bank's mission | pass |
| Each mandate states its purpose; PingFederate's RAR processor asks a federation-aware PDP about every entry, and a purpose outside the mission, or one the partner does not accept, is refused before the customer is asked | e2e: both refusals, with the PDP's reason codes | pass |
| The partner is a federation entity whose enforcement point the anchor configures (PDPs, the federation's layer, required Trust Marks, accepted purposes); its PEP asks the layer and then its PDP, which resolves its own inputs from the federation | e2e: permit with both decision traces | pass |
| Revocation bites everywhere: Trust Mark withdrawn → the federation's layer refuses at the partner; hosted entity revoked → the layer refuses and the bank's own AS refuses the next PAR or token request | e2e | pass |
Secure Enclave keys from a signed helper bundle, inst presence-gated |
PAR spike, helper unit tests | pass |
| App Attest from that bundle, on macOS 27: Apple's chain to its root, the Mac's Full Security and SIP condition, the connector build, an assertion at every renewal; a tampered bundle and an unknown build refused | the signed ClaudeBankConnector.app end to end against the local bank; the bank's verifier against real macOS 27.2 attestations and assertions (finding 26) |
pass |
YubiKey PIV: keys generated on the device, touch per inst signature, PIN once, slot attestation chained to F9 |
18 Python tests and 4 protocol tests against a fake PIV card | hardware run pending |
Findings
1. PingFederate's federation fetch endpoint demanded iss. OpenID Federation 1.0 §8.1.1 defines one fetch parameter, sub; go-oidfed ignores iss entirely. OpenIdFederationServlet refused any fetch without it, so no spec-following resolver could fetch the bank's Subordinate Statement about a hosted agent - every agent chain failed at the first hop. Nobody had noticed because the module's own gateway always sends iss. Fixed: iss is optional and defaults to the entity whose endpoint it is, and an issuer the server does not speak for is still refused.
2. The bank's Entity Configuration carried no jwks_uri for its authorization server. A federation member resolving the bank had no way to reach its token-signing keys through the chain, so it would have had to configure them out of band - which defeats the point. Added to both openid_provider and oauth_authorization_server metadata; the partner now takes the keys from chain-validated metadata and from nowhere else.
3. PingFederate 13.1.3 tightened client assertions. It wants typ: client-authentication+jwt (draft-ietf-oauth-rfc7523bis) and exactly one aud. The attestation filter's bridge assertion had typ: JWT and two audiences, so on 13.1.3 every bridged request failed after the attestation had verified. Fixed in the filter; the one audience is the issuer, which works at both endpoints it covers.
4. Attestation has to work at PAR, not only at the token endpoint. FAPI 2.0 makes PAR mandatory and the attestation draft (§7.6) expects client authentication there. The filter was mapped over the token endpoint only; it now covers PAR too. The PoP's aud is the issuer at both.
5. PingFederate ties a grant to one access token manager. A PAR naming two RFC 8707 resources served by different managers is refused (invalid_target: ... matched multiple access token managers), and a refresh naming a resource the grant does not cover is refused too. A JWT manager's aud is a fixed value, so "one grant, many audiences" is not available. The connector holds one mandate per audience: the bank's API and the partner are approved separately. I think that is the right consent model regardless - letting an agent reach a third party is its own decision for the customer - but it costs a second approval.
6. Revoking the agent did not stop it at the bank. Revoking the hosted entity stopped partners at once (the chain no longer resolves), but PingFederate kept authenticating the agent: an attestation is valid for its whole lifetime and the attester re-mints it. The attestation filter now checks the agent's standing on every PAR and token request - a hosted agent must be active and inside its notAfter. Three limits remain. Access tokens already issued (ten minutes) stay usable at the bank's API, which does not introspect. With the in-memory registry the check has nothing to read after a restart until the hosted-entity servlet first initialises; a durable registry closes that. And an agent the bank does not host at all passes, unless OIDF_ATTESTATION_REQUIRE_HOSTED_AGENT=true makes membership mandatory.
7. act is a JSON string. PingFederate 13 rejects OGNL that references act on a mapping, so the chain is built as a string from the attested agent_id. Every consumer here parses either form. It works, but it is a deviation a strict RFC 8693 consumer would trip over.
8. iat and jti are not in PingFederate's JWT access tokens unless asked for. Both have to be set on each manager ("Include Issued At Claim", "JWT ID Claim Length"); a resource server's replay defences need them.
9. Self-asserted Secure Enclave evidence cannot be told apart from a software key. The automated run enrols software keys as secure-enclave-self-asserted, and the bank accepts them exactly as it would an enclave key. That is why the attester omits key_storage and user_authentication for self-asserted evidence: absent is the honest value. Only App Attest or PIV attestation lets the bank say more.
10. App Attest was not offered for macOS apps - until macOS 27 (finding 26). Apple's capability catalogue - the one Xcode 27 caches from the developer portal - lists App Attest for iOS, tvOS and visionOS, and not for macOS. So there is no App Attest capability to enable on a macOS App ID, and no profile that can authorise the entitlement. Automatic signing signs the helper with the wildcard team profile and drops the entitlement without a word, and DCAppAttestService.isSupported answers false. The API compiles for macOS; it never says yes. A Mac's Secure Enclave path therefore gets self-asserted evidence only (finding 9), and no Xcode sign-in changes that. The Apple-backed way to show a key lives in a Mac's Secure Enclave is Managed Device Attestation, which needs the Mac enrolled in MDM - workable for a bank's own staff, not for its customers. Untested: whether a Mac Catalyst build of the helper, which signs against the iOS SDK, fares any better on Apple silicon.
11. A locked Mac cannot use when-unlocked enclave keys. Key creation and signing fail with -25308 while the screen is locked. fed and dpop are created after-first-unlock, so an agent that already holds a mandate keeps working behind the screen saver; inst, which authenticates the client, stays when-unlocked and presence-gated.
12. PIV attestation has no nonce. A YubiKey slot certificate proves where the key was generated and its PIN and touch policies, but not when. Freshness comes from a proof of possession by the same key over the bank's challenge. The helper needs the PIV management key to create keys; it uses only one the YubiKey keeps PIN-protected, and never changes a PIN, PUK or management key. The PIN goes from a native macOS dialog into the helper - never through Claude or a transcript. And YubiKeys before firmware 5.7 cannot delete PIV keys at all, so a bank_reset leaves the old keys in slots 82-84 and the helper, which never replaces a key it did not create, refuses them. bank_enrol takes replace_yubikey_keys: true as the person's explicit say-so to generate over them.
13. The bank's own API is not federation-gated. Withdrawing the Trust Mark stops the partner but not the bank's API, which trusts its own authorization server. That is by design; finding 6 is where the bank enforces its own revocation.
14. The loopback redirect is a fixed port. http://127.0.0.1:53682/cb is registered exactly at the IdP and at PingFederate (RFC 8252 §7.3), so one flow runs at a time per machine.
15. PingFederate asks its RAR processor from the authorisation endpoint. That is a browser request with no client authentication, so the attestation naming the agent instance is not in it, and the processor - and the PDP behind it - had no agent to hold to a mission. authorization_details is the only thing that survives from PAR to that endpoint, so the attestation filter now writes the agent_id it verified into each entry at PAR, overwriting anything a client put there, and the processor reads and strips it when configured to trust it. The marker never reaches the consent page or the token.
16. Deploying a RAR processor on PingFederate 13.1.3 took three workarounds. A plugin id longer than 32 characters discovers but cannot be instanced, so the processor ships a short-named subclass, au.idp.rar.FedRar. Its supported types were hard-coded, and PingFederate reads them before any instance exists, so extra types now come from the environment. And the 13.1.3 admin API refuses a separate sensitiveFields list: a secret field goes in fields like any other, and the instance's response echoes it back.
17. PingFederate dropped the metadata it vouches for. Its fetch endpoint copied only jwks and metadata_policy from a hosted entity into the Subordinate Statement, so the bank's own description of an agent never reached anyone. It now carries metadata too, applied by every resolver before policy.
18. go-oidfed's lighthouse writes null for unset members. The protected-resource metadata it puts in its Subordinate Statement about the partner carries two null members, and a resolver that treats null as malformed refuses the partner's whole chain. The resolver now reads a null member as absent, and never lets one override a value.
19. One PDP, two checkpoints, its own inputs. The PDP resolves the agent and the resource itself, from the anchor's pinned keys and with no caching, rather than trusting what the enforcement point forwards. At the partner it compares the PEP's forwarded configuration with its own resolution and refuses a mismatch - a stale or tampered PEP. At the bank it reads the partner's resolved metadata to refuse a mandate the partner would never honour. The attestation's ceiling still bounds types and locations at PAR; purpose is the PDP's to judge.
20. On Railway, PingFederate has to see HTTPS itself. Railway's edge terminates TLS and forwards plain HTTP, and the DPoP proofs and attestation PoPs PingFederate checks are checked against URLs it derives from the request. So the demo (deploy/railway) puts Caddy in PingFederate's container: plain HTTP from the edge, TLS to PingFederate's own 9031 with the public Host header passed through. PingFederate answers a Host header its certificate does not name, with or without SNI, so no certificate for the public name is needed. The same Caddy is the bank's one origin: /passkey, /api, /tmi and /attester go to the bank's other services on the private network.
21. An OIDC IdP connection used only for OAuth still needs a session mapping. PingFederate will not save one without an adapter or authentication policy contract mapping, even when only its OAuth attribute mapping will ever run, so the demo maps it to a contract nothing uses. Written in parallel with the token mappings, the connection also deadlocked PingFederate's admin API - both requests hung, with nothing in the log - so that Terraform apply runs one resource at a time.
22. The lighthouse keeps its key in its database as much as in its PEM. A PEM without the database row that records it is ignored and a new key made, which breaks every pin. The demo bakes the key together with the database the lighthouse made it in. And its admin API, once enabled, binds $PORT unless it is given its own (LH_API_ADMIN_PORT) - Caddy's port, in the demo - and the lighthouse then exits without a word.
23. Unlicensed, PingFederate still serves the modules. Without a licence its OAuth endpoints answer with an error page, but the federation servlets carry on, so the anchor enrolled a bank that could not issue a token. PingFederate's own heartbeat, as Railway's health check, is what tells the two apart.
24. A passkey for the mandate too. On Railway the bank's passkey IdP is federated to PingFederate, so the customer approves a mandate with the passkey they enrolled the agent with, and the token's sub is theirs rather than a shared test user's. The IdP took PingFederate as a confidential client beside the connector's public one, and now checks a client's secret before it looks at the code - a wrong secret must not burn someone else's code.
25. A new PingFederate archive is a new bank key. Each authoring run gives PingFederate fresh federation keys, and the anchor's statement about the bank still carried the old ones, so every agent's chain failed at the bank's link: the PDP could not resolve the agent, and PingFederate's RAR processor refused the mandate at enrichment. Checking that the anchor still lists the bank was not enough. The demo's bootstrap now compares the keys each entity's own configuration names with the keys the anchor vouches for, and enrols it again when they differ.
26. macOS 27 brought App Attest to the Mac, and it attests the connector's code. Apple added App Attest to macOS 27 (WWDC26 session 201), and finding 10 turned over: with the Mac upgraded from 26.6 to 27.2 the same signed helper, built with the same wildcard team profile, answered isSupported: true, and its attestation verified at the bank against Apple's real App Attestation Root CA - the first time this platform's verifier met an attestation Apple made rather than one the tests built. What the Mac's attestation carries beyond iOS: the OS (macosx 27.2 (26B5091g), extension 1.2.840.113635.100.8.7) and the Secure Enclave's conditions on the key (1.2.840.113635.100.8.6, the "ACL blob"), which on this Mac gate signing on a requirement Apple calls rsec - read as its Full Security and SIP policy, since Apple has not published the format and one Mac is one data point. The bank now requires that condition of a Mac. Two things that only real assertions showed: Apple signs the nonce, SHA-256(authData ‖ clientDataHash), where the verifier - and the iOS approver app's tests - had signed the concatenation; and an assertion's authenticator data sets the attested-credential-data flag with no credential data behind it and an extensions map after the counter, so the flags cannot be trusted to describe what follows. The verifier now parses the assertion form by its 37-byte header alone.
App Attest vouches for the app that asked, so the connector was moved inside the app it vouches for: make bundle puts Node.js's own signed build and the bundled connector into ClaudeBankConnector.app beside the helper, seals the lot with the team's signature, and the helper serves no other caller. Before any key operation it checks the app's seal (SecStaticCodeCheckValidity, strict, nested code included), then that its parent process is the app's own node, genuine by Node's Developer ID requirement, running exactly the sealed connector.mjs with no options and no NODE_OPTIONS; the connector's hash goes into clientDataHash (jkt | challenge | build), so Apple's signature covers which build asked, and the bank can hold a list of builds it accepts. One line added to the sealed connector broke the seal and every key operation with it; a build the bank did not list was refused at enrolment. Every renewal then carries an assertion by the same App Attest key over the key proof, with a counter the bank records - the instance key on disk, which the enclave will use for whoever holds the file, is no longer enough on its own. One operational fact: App Attest answers unsupported for a bundle LaunchServices has not registered, which is why a plain copy of the app fails until lsregister (or Finder) has seen it. The residual gap is unchanged: none of this proves the instance key is in the Secure Enclave; it proves which app, on which Mac, in what state, asked to bind it, and keeps asking.
27. Two ways for anyone to try it, and what each can honestly claim. The demo site now serves the connector to people's own assistants. Hosted: /mcp is a remote MCP endpoint behind an OAuth 2.1 server of the console's own (packages/demo-console/src/oauth.ts) - protected-resource and server metadata, Dynamic Client Registration and Client ID Metadata Documents, PKCE S256, the resource parameter, rotating refresh tokens - so Claude.ai, ChatGPT and Claude Code can add it by URL. Nobody signs in at that server: the consent page is one click, and what a token stands for is a connector of the assistant's own on the demo server, kept between requests (the process stops when idle and starts again from its state). Who the person is gets settled where it counts, at the bank, when the assistant asks to be linked with a passkey - which is the film's story unchanged. Two small choices worth keeping: a registered client id carries its own redirect URIs, signed with the server's key, so registration needs no store and a restart forgets nothing; and a code is spent by its first presentation whatever the outcome. The bank records these connectors' keys as self-asserted, as it should. Installed: make -C helpers/se-signer mcpb packs the signed app of finding 26 as a Claude Desktop extension. Claude Desktop runs the extension's launcher with its own Node.js, and the launcher starts the app's own signed node on the sealed connector - the one caller the helper accepts - with NODE_OPTIONS stripped (Claude Desktop is entitled to set one; the helper is entitled to refuse a node that honours it), and registers the app with LaunchServices, without which App Attest answers unsupported. Run exactly that way against the local bank, the extension enrolled with App Attest, Full Security shown, its build recorded, and read the accounts. Two things had to change at the bank for a connector on someone's laptop: PingFederate's client now registers the loopback callback beside the console's, and the passkey IdP takes a list of redirect URIs. And one thing stands in the way of anyone's Mac: the app is signed with the team's development certificate, which runs only on Macs registered to the team; a Developer ID certificate and notarisation are the owner's to obtain, and whether App Attest still answers under Developer ID signing is untested.
A pitfall met on the way: bank/scripts/pf-up.sh with SKIP_BUILD=1 reuses the module jars staged in build/pingfederate/modules/, and a merge that added a filter to the runtime's web.xml (main's front-channel auto-registration) left those jars a day behind the assemble script, so PingFederate's runtime context failed to start on Railway with a class-loading error while Railway kept the old deployment serving. deploy/railway/build-pf.sh now re-stages the jars before authoring.
What the bank can say about each key
| Backend | What the bank verifies | key_storage |
user_authentication |
What is still taken on trust |
|---|---|---|---|---|
| Software (tests only) | possession, and the passkey nonce binding |
absent | absent | everything about storage: the key is a file |
| Secure Enclave, self-asserted | the same | absent | absent | that the key is in the enclave at all |
| Secure Enclave + App Attest (iOS; macOS 27 and later) | Apple's attestation that a genuine, unmodified app on genuine Apple hardware asked, committing to the key's thumbprint in clientDataHash; on a Mac, the OS and the Secure Enclave's Full Security and SIP condition on the key, the connector build it asked for, and an assertion by the same key at every renewal |
iso_18045_moderate |
iso_18045_moderate |
App Attest vouches for the app, not for where the committed key lives |
| YubiKey PIV | Yubico's chain (slot → F9 → root), serial, PIN and touch policy, and that the certified key is the presented key | iso_18045_moderate |
iso_18045_moderate with touch-always, else iso_18045_basic |
freshness (no nonce: a PoP covers it), and that the Yubico roots pinned at the bank are right |
Never iso_18045_high: no evidence this platform accepts supports it.
Not done yet
- YubiKey hardware run. Change the factory PIN and PUK, protect the management key once (
helpers/yk-signer/.venv/bin/ykman piv access change-management-key --generate --protect), fetch Yubico's PIV roots intobank/yubico/piv-roots.pem, thenbank_enrolwithbackend: yubikey-piv. - A Mac in reduced security. The key-policy reading in finding 26 comes from one Mac in Full Security; a Mac with reduced security or SIP off should show a different
1.2.840.113635.100.8.6, and has not been seen. Managed Device Attestation remains the route for Macs a bank manages itself. - Developer ID and notarisation for the extension (finding 27), so it opens on a Mac outside the team; then whether App Attest answers under that signature.
- Claude Desktop, by hand. Install
build/ClaudeBankConnector.mcpband link the assistant with Touch ID in the loop. - Claude Desktop, by hand. The Touch ID prompt and the YubiKey touch when the connector is launched by Claude Desktop rather than a terminal.
- PingOne passkeys in place of the local IdP (S4). The Railway demo already signs the customer in to PingFederate through an OIDC IdP connection to the bank's passkey IdP (finding 24); PingOne would take that IdP's place.
- More of the RAR vocabulary: payment types with caps and velocity limits under the same mission and purpose checks; the DPoP-combined PoP variant; attestation challenges.
- A durable hosted-entity registry, so revocation survives a restart and finding 6's window closes.