diff --git a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx new file mode 100644 index 0000000..440fb59 --- /dev/null +++ b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx @@ -0,0 +1,42 @@ +--- +tags: ["api-keys", "agentid", "new-feature", "sdk"] +--- + +## Summary + +Build AgentID sign-in flows with a scoped P-256 credential while keeping private +key material in your own keystore. The API reference now defines a dedicated +public-key lifecycle for generated SDKs, and the new guide provides strict Python +and TypeScript approval helpers. + +### What's new? + +**New endpoints:** + +- `POST /v0/api-keys/public-keys` - Register only a public P-256 JWK and receive the server-owned `api_key_id` used as `kid` +- `GET /v0/api-keys/public-keys` - List public-key credentials without mixing in bearer credentials +- `PATCH /v0/api-keys/public-keys/{api_key_id}` - Rename a credential without mutating security-relevant fields +- `DELETE /v0/api-keys/public-keys/{api_key_id}` - Revoke one public-key credential +- `POST /v0/api-keys/public-keys/agentid-sign-in/revoke-all` - Idempotently invalidate every current AgentID sign-in key in an organization + +**New AgentID endpoint** (served by the AgentID issuer, not part of the AgentMail REST API or generated SDKs — call it directly as shown in the guide): + +- `POST https://auth.agentid.com/authorize/approve` - Submit one strict ES256 approval assertion without a bearer credential + +**New features:** + +- **Scoped credentials**: Register organization-, pod-, or inbox-scoped keys with inherited scope and expiry defaults. +- **Generated SDK contract**: Generate P-256 keys, register only public coordinates, pin the approval header and claims, and keep private keys below model context once corresponding SDK releases are published. + +### Use cases + +Build agents that: + +- Approve AgentID sign-in while the private key stays in a keystore or HSM +- Delegate sign-in authority to one organization, pod, or inbox +- Rotate credentials with a create-new, deploy-new, delete-old sequence +- Fence every active AgentID sign-in key with an idempotent emergency operation + + + Follow the [AgentID Public-Key Authentication guide](https://docs.agentmail.to/agentid-public-key-authentication) for complete Python and TypeScript helpers, lifecycle rules, and the accepted browser-session intent limitation. + diff --git a/fern/definition/api-keys.yml b/fern/definition/api-keys.yml index 2cab110..66f3c86 100644 --- a/fern/definition/api-keys.yml +++ b/fern/definition/api-keys.yml @@ -20,6 +20,148 @@ types: type: datetime docs: Time at which api key was created. + PublicJwkCoordinate: + type: string + validation: + minLength: 43 + maxLength: 43 + pattern: "^[A-Za-z0-9_-]{43}$" + docs: A 32-byte P-256 coordinate encoded as unpadded base64url. + + PublicJwk: + docs: | + A public P-256 JWK. The object accepts exactly `kty`, `crv`, `x`, and `y`. + Private key material such as `d`, embedded key IDs, and all other members + are rejected. The server also rejects coordinates that are not a point on + P-256. + properties: + kty: literal<"EC"> + crv: literal<"P-256"> + x: PublicJwkCoordinate + y: PublicJwkCoordinate + + OrganizationPublicKeyScope: + docs: Organization-wide authority. + properties: {} + + PodPublicKeyScope: + docs: Authority over one live pod and its inboxes. + properties: + id: + type: uuid + docs: ID of the pod. + + InboxPublicKeyScope: + docs: Authority over one live inbox incarnation. + properties: + id: + type: string + validation: + format: email + maxLength: 254 + docs: ID of the inbox. + + PublicKeyScope: + docs: The immutable scope in which a public-key credential can approve AgentID sign-in. + union: + organization: OrganizationPublicKeyScope + pod: PodPublicKeyScope + inbox: InboxPublicKeyScope + + PublicKeyMaterial: + docs: Registered public key material and its server-computed RFC 7638 thumbprint. + properties: + jwk: PublicJwk + fingerprint: + type: string + validation: + minLength: 43 + maxLength: 43 + pattern: "^[A-Za-z0-9_-]{43}$" + docs: RFC 7638 SHA-256 JWK thumbprint encoded as unpadded base64url. + + PublicKeyCredential: + docs: | + An AgentID sign-in credential. `type` and `api_key_id` are server-owned; + use `api_key_id` as the JWS `kid`. This response never contains a bearer + secret or private key. + properties: + api_key_id: + type: uuid + docs: Server-generated credential ID. Store this value as the signing key's `kid`. + type: + type: literal<"public_key"> + docs: Server-owned credential discriminator. Callers cannot select or update it. + name: + type: Name + docs: Human-readable credential name. + public_key: PublicKeyMaterial + scope: PublicKeyScope + expires_at: + type: optional + docs: Immutable absolute expiry. Omitted when the credential does not expire. + revoked_at: + type: optional + docs: Present when organization-wide revoke-all invalidated this credential generation. + created_at: datetime + updated_at: datetime + + CreatePublicKeyRequest: + docs: | + Register only a public P-256 JWK. Credential type, `api_key_id`, sign-in + eligibility, permissions, and generation are server-owned and are not + request properties. + properties: + public_key: PublicJwk + name: + type: optional + validation: + minLength: 1 + maxLength: 256 + docs: Defaults to `AgentID key {first eight fingerprint characters}`. + scope: + type: optional + docs: | + Omit to inherit the registering bearer key's exact scope. An explicit + scope must be the caller's scope or a live descendant. + expires_at: + type: optional + docs: | + Future absolute expiry. Omit to inherit the registering bearer key's + expiry. A child credential cannot outlive its creator. + + UpdatePublicKeyNameRequest: + docs: | + Rename a public-key credential. Key material, ID, type, scope, sign-in + eligibility, permissions, generation, and expiry are immutable. + properties: + name: + type: string + validation: + minLength: 1 + maxLength: 256 + + ListPublicKeysResponse: + properties: + count: global.Count + next_page_token: optional + public_keys: + type: list + docs: Public-key credentials only, ordered by creation time descending by default. + + RevokeAllAgentIdSignInKeysResponse: + docs: Permanent idempotency receipt for an organization-wide AgentID sign-in key revocation. + properties: + previous_generation: + type: integer + validation: + min: 0 + current_generation: + type: integer + validation: + min: 1 + revoked_at: datetime + ApiKeyPermissions: docs: Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted. properties: @@ -116,6 +258,9 @@ types: api_key_create: type: optional docs: Create API keys. + api_key_update: + type: optional + docs: Update API keys. api_key_delete: type: optional docs: Delete API keys. @@ -226,3 +371,95 @@ service: api_key_id: ApiKeyId errors: - global.NotFoundError + + listPublicKeys: + method: GET + path: /public-keys + display-name: List Public-Key Credentials + docs: | + List only public-key credentials visible to the bearer caller's scope. + Bearer credentials are never returned, even though both credential types + share storage and pagination indexes. Requires `api_key_read`. + request: + name: ListPublicKeysRequest + query-parameters: + limit: optional + page_token: optional + ascending: optional + response: ListPublicKeysResponse + + createPublicKey: + method: POST + path: /public-keys + display-name: Register Public-Key Credential + docs: | + Register a public P-256 JWK using an existing AgentMail bearer API key + with `api_key_create`. Re-registering the same JWK creates a new + credential ID; it does not replace or recover an earlier credential. + The private key must never be sent to AgentMail. + request: CreatePublicKeyRequest + response: PublicKeyCredential + errors: + - global.ValidationError + - global.ConflictError + + updatePublicKeyName: + method: PATCH + path: /public-keys/{api_key_id} + display-name: Rename Public-Key Credential + docs: | + Rename the credential. All security-relevant fields are immutable. + Requires `api_key_update`. + path-parameters: + api_key_id: + type: uuid + docs: Public-key credential ID returned by registration. + request: UpdatePublicKeyNameRequest + response: PublicKeyCredential + errors: + - global.ValidationError + - global.NotFoundError + + revokePublicKey: + method: DELETE + path: /public-keys/{api_key_id} + display-name: Revoke Public-Key Credential + docs: | + Permanently revoke one public-key credential. This hard-deletes the + credential; repeating the request returns not found. Requires + `api_key_delete`. + path-parameters: + api_key_id: + type: uuid + docs: Public-key credential ID returned by registration. + response: + status-code: 204 + errors: + - global.NotFoundError + + revokeAllAgentIdSignInKeys: + method: POST + path: /public-keys/agentid-sign-in/revoke-all + display-name: Revoke All AgentID Sign-In Keys + docs: | + Invalidate every current public-key credential in the caller's + organization by advancing its AgentID key generation. The caller must be + organization-scoped and either have `api_key_delete` or, for a verified + self-serve agent organization, use an unrestricted unmanaged bearer + credential. No request body is accepted. + + `Idempotency-Key` is required and must be a UUID. Reusing the same UUID + returns the original permanent receipt without advancing the generation + again. A new UUID performs a new generation advance. + request: + name: RevokeAllAgentIdSignInKeysRequest + headers: + Idempotency-Key: + type: string + validation: + format: uuid + docs: Required UUID identifying this revoke-all operation permanently. + response: RevokeAllAgentIdSignInKeysResponse + errors: + - global.ValidationError + - global.ConflictError diff --git a/fern/docs.yml b/fern/docs.yml index c236557..477add6 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -314,6 +314,9 @@ navigation: # path: pages/integrations/crewai.mdx - section: Guides contents: + - page: AgentID Public-Key Authentication + icon: fa-solid fa-key + path: pages/guides/agentid-public-key-authentication.mdx - page: Sending & Receiving Email icon: fa-solid fa-right-left path: pages/guides/sending-receiving-email.mdx diff --git a/fern/pages/guides/agentid-public-key-authentication.mdx b/fern/pages/guides/agentid-public-key-authentication.mdx new file mode 100644 index 0000000..562058d --- /dev/null +++ b/fern/pages/guides/agentid-public-key-authentication.mdx @@ -0,0 +1,413 @@ +--- +title: AgentID Public-Key Authentication +subtitle: Register a scoped P-256 key and sign one AgentID approval without exposing the private key. +slug: agentid-public-key-authentication +description: Generate and store a P-256 key, register its public JWK, and submit a strict signed AgentID approval. +--- + +AgentID public-key credentials let an agent prove possession of a P-256 private +key while approving an AgentID sign-in. Registration uses an existing AgentMail +bearer API key. Approval uses only a compact signature and never sends that +bearer key or the private key to AgentID. + + + Generate and use the private key in a keystore, HSM, KMS, or a small trusted + signing process. Give the model an opaque signing capability, not the private + JWK, PEM, environment variable, tool output, log entry, prompt, trace, or + conversation history. + + +## Protocol at a glance + +1. Generate a P-256 key pair in trusted code and persist the private key in your + keystore. +2. Export only `{kty: "EC", crv: "P-256", x, y}` and register it at + `POST /v0/api-keys/public-keys` with an existing AgentMail bearer API key. +3. Store the returned `api_key_id` beside the private-key handle. It is the JWS + `kid`; do not compute or choose it yourself. +4. For one pending authorization transaction, sign exactly `{jti, inbox_id}` + with ES256 and protected `typ: agentid-approval+jwt`. +5. POST exactly `{assertion, inbox_id}` to + `https://auth.agentid.com/authorize/approve` without bearer authorization. + Success is `204 No Content`. + +Public-key credentials are only AgentID sign-in credentials. They cannot replace +an AgentMail bearer API key for normal REST API calls. + +## Generate and register a key + +The registration endpoint rejects private `d`, unknown JWK members, non-P-256 +curves, malformed coordinates, and coordinates that are not on P-256. The +server computes the RFC 7638 SHA-256 fingerprint and returns it; compare or log +the public fingerprint when you need an audit handle, never the private key. + + + The generated SDK methods in this guide require an AgentMail Python and + TypeScript SDK release that includes the public-key credential endpoints. If + your installed client does not expose these methods yet, use the REST API or + upgrade after that release is published. + + +After that SDK release, install the Python example dependencies with +`pip install agentmail cryptography PyJWT httpx`, or the TypeScript dependencies +with `npm install agentmail jose`. + + + +```python title="Python" +from __future__ import annotations + +import base64 +from datetime import datetime +from typing import Any, Dict, Literal, Optional, TypedDict, Union + +from agentmail import AgentMail +from cryptography.hazmat.primitives.asymmetric import ec + + +class OrganizationScope(TypedDict): + type: Literal["organization"] + + +class PodScope(TypedDict): + type: Literal["pod"] + id: str + + +class InboxScope(TypedDict): + type: Literal["inbox"] + id: str + + +Scope = Union[OrganizationScope, PodScope, InboxScope] + + +def b64url_coordinate(value: int) -> str: + return base64.urlsafe_b64encode(value.to_bytes(32, "big")).rstrip(b"=").decode() + + +def public_jwk(private_key: ec.EllipticCurvePrivateKey) -> Dict[str, str]: + numbers = private_key.public_key().public_numbers() + return { + "kty": "EC", + "crv": "P-256", + "x": b64url_coordinate(numbers.x), + "y": b64url_coordinate(numbers.y), + } + + +def register_agentid_key( + client: AgentMail, + private_key: ec.EllipticCurvePrivateKey, + *, + scope: Optional[Scope] = None, + name: Optional[str] = None, + expires_at: Optional[datetime] = None, +): + request: Dict[str, Any] = {"public_key": public_jwk(private_key)} + if scope is not None: + request["scope"] = scope + if name is not None: + request["name"] = name + if expires_at is not None: + request["expires_at"] = expires_at + return client.api_keys.create_public_key(**request) + + +# Generate inside your keystore in production. This in-process object is only a +# minimal example; persist it before registration so a crash cannot orphan the kid. +private_key = ec.generate_private_key(ec.SECP256R1()) +client = AgentMail(api_key="YOUR_EXISTING_AGENTMAIL_API_KEY") +credential = register_agentid_key( + client, + private_key, + name="production signer", + scope={"type": "inbox", "id": "agent@example.com"}, +) + +# Store this mapping in trusted application state. +key_record = { + "keystore_handle": "opaque-keystore-handle", + "kid": str(credential.api_key_id), +} +``` + +```typescript title="TypeScript" +import { generateKeyPairSync, type KeyObject } from "node:crypto"; +import { AgentMailClient } from "agentmail"; + +type Scope = + | { type: "organization" } + | { type: "pod"; id: string } + | { type: "inbox"; id: string }; + +function publicJwk(publicKey: KeyObject) { + const jwk = publicKey.export({ format: "jwk" }); + if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) { + throw new Error("expected a P-256 public key"); + } + // Select the four public members explicitly. Do not spread an exported JWK. + return { kty: "EC" as const, crv: "P-256" as const, x: jwk.x, y: jwk.y }; +} + +async function registerAgentIdKey( + client: AgentMailClient, + publicKey: KeyObject, + options: { scope?: Scope; name?: string; expiresAt?: Date } = {}, +) { + return client.apiKeys.createPublicKey({ + publicKey: publicJwk(publicKey), + scope: options.scope, + name: options.name, + expiresAt: options.expiresAt, + }); +} + +// Generate inside your keystore in production. Persist the private-key handle +// before registration so a crash cannot orphan the returned kid. +const { privateKey, publicKey } = generateKeyPairSync("ec", { + namedCurve: "prime256v1", +}); +const client = new AgentMailClient({ apiKey: "YOUR_EXISTING_AGENTMAIL_API_KEY" }); +const credential = await registerAgentIdKey(client, publicKey, { + name: "production signer", + scope: { type: "inbox", id: "agent@example.com" }, +}); + +const keyRecord = { + keystoreHandle: "opaque-keystore-handle", + kid: credential.apiKeyId, +}; +``` + + + +The examples keep a process-local private key only to show the types. In a +production helper, make the signer accept an opaque keystore handle and return a +signature; do not make private key bytes an application-level return value. + +## Scope and expiry + +Omitting `scope` inherits the registering bearer key's exact live scope. An +explicit scope may be the caller's scope or a live descendant, never an ancestor +or sibling. + +```json title="Organization scope" +{ "type": "organization" } +``` + +```json title="Pod scope" +{ "type": "pod", "id": "33333333-3333-4333-8333-333333333333" } +``` + +```json title="Inbox scope" +{ "type": "inbox", "id": "agent@example.com" } +``` + +For `expires_at`, omission inherits the registering bearer credential's expiry. +If that bearer does not expire, the public-key credential does not expire. An +explicit expiry must be in the future and cannot be later than the creator's +expiry. Scope, key material, AgentID eligibility, and expiry are immutable after +registration; only `name` can be patched. + +## Sign and submit one approval + +The protected header and payload are intentionally smaller than a general JWT: + +```json title="Protected header" +{ + "alg": "ES256", + "typ": "agentid-approval+jwt", + "kid": "api_key_id returned by registration" +} +``` + +```json title="Signed payload" +{ "jti": "transaction challenge", "inbox_id": "agent@example.com" } +``` + +Do not add `aud`, `iat`, `exp`, `nonce`, `scope`, or any other claim. Do not add +`jwk`, `jku`, `x5u`, `x5c`, or `crit` to the protected header. The transaction's +server-side expiry is authoritative. The assertion must be a three-segment +compact JWS no larger than 2 KiB; `jti` is 1–128 characters and `inbox_id` is +1–254 characters and must identify an email inbox. + + + +```python title="Python" +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import ec + +AGENTID_APPROVE_URL = "https://auth.agentid.com/authorize/approve" + + +def approve_agentid_transaction( + *, + private_key: ec.EllipticCurvePrivateKey, + api_key_id: str, + jti: str, + inbox_id: str, +) -> None: + assertion = jwt.encode( + {"jti": jti, "inbox_id": inbox_id}, + private_key, + algorithm="ES256", + headers={"alg": "ES256", "typ": "agentid-approval+jwt", "kid": api_key_id}, + ) + + response = httpx.post( + AGENTID_APPROVE_URL, + json={"assertion": assertion, "inbox_id": inbox_id}, + # Deliberately no Authorization header and no browser cookies. + headers={"Content-Type": "application/json"}, + timeout=10, + ) + response.raise_for_status() + if response.status_code != 204: + raise RuntimeError(f"unexpected approval status {response.status_code}") +``` + +```typescript title="TypeScript" +import type { KeyObject } from "node:crypto"; +import { CompactSign } from "jose"; + +const AGENTID_APPROVE_URL = "https://auth.agentid.com/authorize/approve"; + +async function approveAgentIdTransaction(input: { + privateKey: KeyObject; + apiKeyId: string; + jti: string; + inboxId: string; +}): Promise { + const payload = new TextEncoder().encode( + JSON.stringify({ jti: input.jti, inbox_id: input.inboxId }), + ); + const assertion = await new CompactSign(payload) + .setProtectedHeader({ + alg: "ES256", + typ: "agentid-approval+jwt", + kid: input.apiKeyId, + }) + .sign(input.privateKey); + + const response = await fetch(AGENTID_APPROVE_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + // Deliberately no Authorization header, credentials option, or browser cookie. + body: JSON.stringify({ assertion, inbox_id: input.inboxId }), + }); + if (response.status !== 204) { + throw new Error(`AgentID approval failed with ${response.status}`); + } +} +``` + + + +The unsigned `inbox_id` in the JSON body is an ergonomic duplicate and must be +byte-for-byte equal to the signed claim. The server resolves `kid` only against a +stored public-key credential, verifies the signature, validates the transaction, +and rechecks the key, organization, scope, inbox, generation, and expiry before +committing one approval. Concurrent or repeated submissions have one winner. + +## List, rename, revoke, and rotate + +The generated clients for this contract expose dedicated lifecycle methods. +Legacy `api_keys.list`, `api_keys.create`, and `api_keys.delete` remain +bearer-only and have no public-key request member. + + + +```python title="Python" +# Public-key list results never include bearer credentials. +page = client.api_keys.list_public_keys(limit=20) + +# Name is the only mutable property. +renamed = client.api_keys.update_public_key_name( + credential.api_key_id, + name="production signer 2026-08", +) + +# Rotate by creating the replacement first, deploying its new kid, then deleting old. +replacement = register_agentid_key(client, replacement_private_key, name="replacement") +deploy_kid_and_keystore_handle(replacement.api_key_id, replacement_private_key_handle) +client.api_keys.revoke_public_key(credential.api_key_id) +``` + +```typescript title="TypeScript" +// Public-key list results never include bearer credentials. +const page = await client.apiKeys.listPublicKeys({ limit: 20 }); + +// Name is the only mutable property. +const renamed = await client.apiKeys.updatePublicKeyName(credential.apiKeyId, { + name: "production signer 2026-08", +}); + +// Rotate by creating the replacement first, deploying its new kid, then deleting old. +const replacement = await registerAgentIdKey(client, replacementPublicKey, { + name: "replacement", +}); +await deployKidAndKeystoreHandle(replacement.apiKeyId, replacementPrivateKeyHandle); +await client.apiKeys.revokePublicKey(credential.apiKeyId); +``` + + + +Registration never updates in place. Even registering identical JWK coordinates +again returns a new `api_key_id`; store and use that new value as `kid`. Rotation +is therefore create new, deploy new, then delete old. Never reuse an old `kid` +for new key material. + +For an emergency organization-wide fence, call +`POST /v0/api-keys/public-keys/agentid-sign-in/revoke-all` with an +organization-scoped bearer credential and a required UUID `Idempotency-Key`. +The caller normally needs `api_key_delete`. A verified self-serve agent +organization may instead use an unrestricted unmanaged bearer credential for +this emergency operation. The request has no body. Repeating the same UUID +returns the original `{previous_generation, current_generation, revoked_at}` +receipt and does not advance the generation twice. A new UUID advances it again. +Existing rows remain visible with `revoked_at` for audit; individually revoked +keys are deleted. + + + +```python title="Python" +import uuid + +receipt = client.api_keys.revoke_all_agent_id_sign_in_keys( + idempotency_key=str(uuid.uuid4()), +) +print(receipt.previous_generation, receipt.current_generation) +``` + +```typescript title="TypeScript" +import { randomUUID } from "node:crypto"; + +const receipt = await client.apiKeys.revokeAllAgentIdSignInKeys({ + idempotencyKey: randomUUID(), +}); +console.log(receipt.previousGeneration, receipt.currentGeneration); +``` + + + +## Intent and browser-session limitation + + + The approval assertion proves that the key holder approved the server-created + transaction identified by `jti` for one inbox. It does not prove that the key + holder initiated the transaction, controls the browser session, inspected the + relying party, or intended the relying party's action. + + +An attacker can start a valid authorization transaction in the attacker's own +browser, induce an agent to sign that transaction's `jti`, and then continue in +the same attacker browser session. AgentID's per-transaction cookie binding +prevents a different browser from continuing the flow, but it does not remove +this accepted transaction-intent/session-swap residual. + +If your product requires intent assurance, bind the displayed relying party and +transaction to an authenticated, trusted out-of-band instruction before calling +the signing helper. Do not claim that signature validity alone verifies user +intent.