From 2b09014c2df252ca7ff21272928baa78913f53af Mon Sep 17 00:00:00 2001 From: shalim786 Date: Mon, 20 Jul 2026 14:16:22 -0700 Subject: [PATCH 1/4] docs: publish AgentID public-key auth contract --- conformance/README.md | 18 + conformance/agentid-key-auth-v1.json | 69 +++ .../2026-07-20-agentid-public-key-auth.mdx | 40 ++ fern/definition/api-keys.yml | 233 ++++++++++ fern/docs.yml | 3 + .../agentid-public-key-authentication.mdx | 411 ++++++++++++++++++ package.json | 1 + scripts/validate-agentid-key-auth-fixtures.ts | 231 ++++++++++ 8 files changed, 1006 insertions(+) create mode 100644 conformance/README.md create mode 100644 conformance/agentid-key-auth-v1.json create mode 100644 fern/changelog/2026-07-20-agentid-public-key-auth.mdx create mode 100644 fern/pages/guides/agentid-public-key-authentication.mdx create mode 100644 scripts/validate-agentid-key-auth-fixtures.ts diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..c767fef --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,18 @@ +# AgentID key-auth conformance fixtures + +`agentid-key-auth-v1.json` is the canonical, language-neutral fixture for the +AgentID public-key registration and approval protocol. Backend and SDK suites +should consume this file directly (or vendor it byte-for-byte with its version) +instead of retyping the values. + +The fixture pins: + +- the strict public P-256 JWK and its RFC 7638 SHA-256 thumbprint; +- `api_key_id` as the approval assertion `kid`; +- the exact `ES256` / `agentid-approval+jwt` protected header; +- the exact `{jti, inbox_id}` payload and one valid compact JWS; +- cryptographically valid semantic failures and signature-breaking mutations. + +The private JWK is intentionally public test material. Never register or use it +outside conformance tests. Run `npm run fixtures:check` to verify every vector +offline with Node's standard crypto implementation. diff --git a/conformance/agentid-key-auth-v1.json b/conformance/agentid-key-auth-v1.json new file mode 100644 index 0000000..2ac5cc6 --- /dev/null +++ b/conformance/agentid-key-auth-v1.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "description": "AgentID P-256 thumbprint and compact approval assertion conformance vectors. The private key is test-only and must never be used for a real credential.", + "key": { + "private_jwk_test_only": { + "kty": "EC", + "crv": "P-256", + "x": "axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY", + "y": "T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", + "d": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE" + }, + "public_jwk": { + "kty": "EC", + "crv": "P-256", + "x": "axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY", + "y": "T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU" + }, + "rfc7638_sha256_thumbprint": "xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M" + }, + "credential": { + "api_key_id": "55555555-5555-4555-8555-555555555555" + }, + "approval": { + "protected_header": { + "alg": "ES256", + "typ": "agentid-approval+jwt", + "kid": "55555555-5555-4555-8555-555555555555" + }, + "payload": { + "jti": "Zm9vYmFyMTIzNDU2Nzg5MA", + "inbox_id": "agent@example.com" + }, + "compact_assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", + "request_body": { + "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", + "inbox_id": "agent@example.com" + } + }, + "rejected_assertions": [ + { + "name": "wrong_typ", + "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSJ9.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.kxkutVjbyc0LC0RPxmGjBsCiUrCq-EcKJBh_hJthRFVih3pM6l-SdEg_BE_twLUdC5hLKorzl2WYRD3h1me9Zg", + "signature_valid": true, + "expected_status": 401, + "reason": "The protected typ is not agentid-approval+jwt." + }, + { + "name": "extra_claim", + "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMH0.DcUb9BgF9eD_joB4PhT23W22mf50dKphuVrupjTpvkudsCTYODRC-oDEHCznTYNlTXt84IW1M_YQCaNIPptviQ", + "signature_valid": true, + "expected_status": 400, + "reason": "The signed payload contains iat in addition to jti and inbox_id." + }, + { + "name": "tampered_header", + "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1IiwiY3JpdCI6WyJleHAiXX0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", + "signature_valid": false, + "expected_status": 401, + "reason": "The signed protected header was modified and also carries forbidden crit." + }, + { + "name": "tampered_payload", + "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJvdGhlckBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", + "signature_valid": false, + "expected_status": 401, + "reason": "The signed inbox_id was modified without a new signature." + } + ] +} 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..3d1e106 --- /dev/null +++ b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx @@ -0,0 +1,40 @@ +--- +tags: ["api-keys", "agentid", "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 and SDKs now expose a +dedicated public-key lifecycle, and the new guide provides strict Python and +TypeScript approval helpers plus shared conformance vectors. + +### 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 +- `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. +- **Safe SDK helpers**: Generate P-256 keys, register only public coordinates, pin the approval header and claims, and keep private keys below model context. +- **Portable conformance**: Verify RFC 7638 thumbprints and compact JWS acceptance and rejection across backend and SDK implementations. + +### 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..151e370 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: @@ -226,3 +368,94 @@ 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 authorized to delete API keys. 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..5b9de0c --- /dev/null +++ b/fern/pages/guides/agentid-public-key-authentication.mdx @@ -0,0 +1,411 @@ +--- +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. + +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 + +Use the dedicated generated 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 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 stable 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. + +## Conformance vectors + +The repository publishes the versioned +[`agentid-key-auth-v1.json`](https://github.com/agentmail-to/agentmail-docs/blob/main/conformance/agentid-key-auth-v1.json) +fixture for backend and SDK suites. It includes a test-only P-256 private key, +public JWK, RFC 7638 thumbprint, valid compact assertion, a validly signed wrong +`typ`, a validly signed extra claim, and signature-breaking header and payload +mutations. Never use its private key for a real credential. diff --git a/package.json b/package.json index 24ca034..2b561d9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "docs:preview": "tsx bin/agentmail-docs.ts preview", "docs:publish": "tsx bin/agentmail-docs.ts publish", + "fixtures:check": "node scripts/validate-agentid-key-auth-fixtures.ts", "lint": "tsc --noEmit", "format": "prettier --write \"bin/**/*.ts\" package.json tsconfig.json" }, diff --git a/scripts/validate-agentid-key-auth-fixtures.ts b/scripts/validate-agentid-key-auth-fixtures.ts new file mode 100644 index 0000000..fa5e5ac --- /dev/null +++ b/scripts/validate-agentid-key-auth-fixtures.ts @@ -0,0 +1,231 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + verify, +} from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Jwk = { + kty: "EC"; + crv: "P-256"; + x: string; + y: string; + d?: string; +}; + +type Fixture = { + version: number; + key: { + private_jwk_test_only: Jwk; + public_jwk: Jwk; + rfc7638_sha256_thumbprint: string; + }; + credential: { api_key_id: string }; + approval: { + protected_header: Record; + payload: Record; + compact_assertion: string; + request_body: { assertion: string; inbox_id: string }; + }; + rejected_assertions: Array<{ + name: string; + assertion: string; + signature_valid: boolean; + expected_status: number; + reason: string; + }>; +}; + +const fixturePath = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "conformance", + "agentid-key-auth-v1.json", +); +const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +const fail = (message: string): never => { + throw new Error(message); +}; +const equal = (actual: unknown, expected: unknown, message: string) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(message); +}; +const decode = (segment: string) => + JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record< + string, + unknown + >; +const parts = (assertion: string) => { + const value = assertion.split("."); + if (value.length !== 3 || value.some((segment) => segment.length === 0)) { + fail("assertion is not a compact JWS"); + } + return value as [string, string, string]; +}; + +const publicJwk = fixture.key.public_jwk; +equal( + Object.keys(publicJwk), + ["kty", "crv", "x", "y"], + "public JWK must expose exactly kty, crv, x, and y", +); +if ( + publicJwk.kty !== "EC" || + publicJwk.crv !== "P-256" || + !/^[A-Za-z0-9_-]{43}$/.test(publicJwk.x) || + !/^[A-Za-z0-9_-]{43}$/.test(publicJwk.y) +) { + fail("public JWK is not a strict P-256 key"); +} + +const canonicalJwk = JSON.stringify({ + crv: publicJwk.crv, + kty: publicJwk.kty, + x: publicJwk.x, + y: publicJwk.y, +}); +const thumbprint = createHash("sha256") + .update(canonicalJwk) + .digest("base64url"); +if (thumbprint !== fixture.key.rfc7638_sha256_thumbprint) { + fail("RFC 7638 thumbprint does not match"); +} + +const derivedPublic = createPublicKey( + createPrivateKey({ key: fixture.key.private_jwk_test_only, format: "jwk" }), +).export({ format: "jwk" }); +equal( + { + kty: derivedPublic.kty, + crv: derivedPublic.crv, + x: derivedPublic.x, + y: derivedPublic.y, + }, + publicJwk, + "test private JWK does not derive the public JWK", +); + +const verifyCompact = (assertion: string) => { + const [header, payload, signature] = parts(assertion); + return verify( + "sha256", + Buffer.from(`${header}.${payload}`), + { + key: createPublicKey({ key: publicJwk, format: "jwk" }), + dsaEncoding: "ieee-p1363", + }, + Buffer.from(signature, "base64url"), + ); +}; + +const [header, payload] = parts(fixture.approval.compact_assertion); +equal( + decode(header), + fixture.approval.protected_header, + "valid protected header changed", +); +equal( + decode(payload), + fixture.approval.payload, + "valid signed payload changed", +); +equal( + fixture.approval.protected_header, + { + alg: "ES256", + typ: "agentid-approval+jwt", + kid: fixture.credential.api_key_id, + }, + "protected header is not exactly the AgentID contract", +); +equal( + Object.keys(fixture.approval.payload), + ["jti", "inbox_id"], + "approval payload must contain exactly jti and inbox_id", +); +if (!verifyCompact(fixture.approval.compact_assertion)) { + fail("valid compact assertion signature failed verification"); +} +if ( + fixture.approval.request_body.assertion !== fixture.approval.compact_assertion +) { + fail("request body does not carry the valid assertion"); +} +if ( + fixture.approval.request_body.inbox_id !== fixture.approval.payload.inbox_id +) { + fail("unsigned inbox_id is not byte-for-byte equal to the signed claim"); +} + +const expectedRejections = { + wrong_typ: true, + extra_claim: true, + tampered_header: false, + tampered_payload: false, +}; +equal( + Object.fromEntries( + fixture.rejected_assertions.map((vector) => [ + vector.name, + vector.signature_valid, + ]), + ), + expectedRejections, + "rejection vector inventory changed", +); +for (const vector of fixture.rejected_assertions) { + if (verifyCompact(vector.assertion) !== vector.signature_valid) { + fail(`${vector.name} signature expectation does not match`); + } + if (!vector.reason || ![400, 401].includes(vector.expected_status)) { + fail(`${vector.name} does not describe its expected rejection`); + } +} + +const rejected = Object.fromEntries( + fixture.rejected_assertions.map((vector) => [vector.name, vector]), +); +const [wrongTypHeader, wrongTypPayload] = parts(rejected.wrong_typ.assertion); +equal( + decode(wrongTypHeader), + { alg: "ES256", typ: "JWT", kid: fixture.credential.api_key_id }, + "wrong_typ must be validly signed with the wrong typ only", +); +equal( + decode(wrongTypPayload), + fixture.approval.payload, + "wrong_typ payload changed", +); + +const [extraClaimHeader, extraClaimPayload] = parts( + rejected.extra_claim.assertion, +); +equal( + decode(extraClaimHeader), + fixture.approval.protected_header, + "extra_claim protected header changed", +); +equal( + decode(extraClaimPayload), + { ...fixture.approval.payload, iat: 1_700_000_000 }, + "extra_claim must add only iat to the exact payload", +); + +const [tamperedHeader] = parts(rejected.tampered_header.assertion); +equal( + decode(tamperedHeader), + { ...fixture.approval.protected_header, crit: ["exp"] }, + "tampered_header must add forbidden crit without resigning", +); + +const [, tamperedPayload] = parts(rejected.tampered_payload.assertion); +equal( + decode(tamperedPayload), + { ...fixture.approval.payload, inbox_id: "other@example.com" }, + "tampered_payload must change only inbox_id without resigning", +); + +console.log(`AgentID key-auth fixture v${fixture.version} is valid.`); From e9c9de46685eed1dc5f5b1fbad305500b5e4d94e Mon Sep 17 00:00:00 2001 From: shalim786 Date: Mon, 20 Jul 2026 14:37:51 -0700 Subject: [PATCH 2/4] docs: align AgentID public-key contract --- .../2026-07-20-agentid-public-key-auth.mdx | 10 ++++---- fern/definition/api-keys.yml | 8 ++++-- .../agentid-public-key-authentication.mdx | 25 +++++++++++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx index 3d1e106..b9855a8 100644 --- a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx +++ b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx @@ -1,13 +1,13 @@ --- -tags: ["api-keys", "agentid", "sdk"] +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 and SDKs now expose a -dedicated public-key lifecycle, and the new guide provides strict Python and -TypeScript approval helpers plus shared conformance vectors. +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 plus shared conformance vectors. ### What's new? @@ -23,7 +23,7 @@ TypeScript approval helpers plus shared conformance vectors. **New features:** - **Scoped credentials**: Register organization-, pod-, or inbox-scoped keys with inherited scope and expiry defaults. -- **Safe SDK helpers**: Generate P-256 keys, register only public coordinates, pin the approval header and claims, and keep private keys below model context. +- **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. - **Portable conformance**: Verify RFC 7638 thumbprints and compact JWS acceptance and rejection across backend and SDK implementations. ### Use cases diff --git a/fern/definition/api-keys.yml b/fern/definition/api-keys.yml index 151e370..66f3c86 100644 --- a/fern/definition/api-keys.yml +++ b/fern/definition/api-keys.yml @@ -258,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. @@ -441,8 +444,9 @@ service: 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 authorized to delete API keys. No request body is - accepted. + 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 diff --git a/fern/pages/guides/agentid-public-key-authentication.mdx b/fern/pages/guides/agentid-public-key-authentication.mdx index 5b9de0c..3aba0bd 100644 --- a/fern/pages/guides/agentid-public-key-authentication.mdx +++ b/fern/pages/guides/agentid-public-key-authentication.mdx @@ -41,7 +41,14 @@ 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. -Install the Python example dependencies with + + 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`. @@ -306,8 +313,9 @@ committing one approval. Concurrent or repeated submissions have one winner. ## List, rename, revoke, and rotate -Use the dedicated generated methods. Legacy `api_keys.list`, `api_keys.create`, -and `api_keys.delete` remain bearer-only and have no public-key request member. +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. @@ -354,10 +362,13 @@ 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 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. +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. From 00dcc8eff3c863f542483822ca281a276d17af0e Mon Sep 17 00:00:00 2001 From: shalim786 Date: Mon, 20 Jul 2026 14:48:36 -0700 Subject: [PATCH 3/4] docs: fix cookie wording and clarify approve endpoint in AgentID guide - guide: "stable cookie binding" -> "per-transaction cookie binding" to match the random per-transaction transaction cookie the backend sets - changelog: separate auth.agentid.com/authorize/approve from the Fern/SDK endpoints, noting it is issuer-served and called directly Co-Authored-By: Claude Opus 4.8 --- fern/changelog/2026-07-20-agentid-public-key-auth.mdx | 3 +++ fern/pages/guides/agentid-public-key-authentication.mdx | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx index b9855a8..33704a8 100644 --- a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx +++ b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx @@ -18,6 +18,9 @@ and TypeScript approval helpers plus shared conformance vectors. - `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:** diff --git a/fern/pages/guides/agentid-public-key-authentication.mdx b/fern/pages/guides/agentid-public-key-authentication.mdx index 3aba0bd..476a1e8 100644 --- a/fern/pages/guides/agentid-public-key-authentication.mdx +++ b/fern/pages/guides/agentid-public-key-authentication.mdx @@ -403,9 +403,9 @@ console.log(receipt.previousGeneration, receipt.currentGeneration); 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 stable cookie binding prevents a -different browser from continuing the flow, but it does not remove this accepted -transaction-intent/session-swap residual. +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 From 6899d37c1650e7f91bafe6f6339323b6578ee08d Mon Sep 17 00:00:00 2001 From: shalim786 Date: Mon, 20 Jul 2026 14:54:17 -0700 Subject: [PATCH 4/4] docs: remove AgentID conformance fixtures --- conformance/README.md | 18 -- conformance/agentid-key-auth-v1.json | 69 ------ .../2026-07-20-agentid-public-key-auth.mdx | 3 +- .../agentid-public-key-authentication.mdx | 9 - package.json | 1 - scripts/validate-agentid-key-auth-fixtures.ts | 231 ------------------ 6 files changed, 1 insertion(+), 330 deletions(-) delete mode 100644 conformance/README.md delete mode 100644 conformance/agentid-key-auth-v1.json delete mode 100644 scripts/validate-agentid-key-auth-fixtures.ts diff --git a/conformance/README.md b/conformance/README.md deleted file mode 100644 index c767fef..0000000 --- a/conformance/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# AgentID key-auth conformance fixtures - -`agentid-key-auth-v1.json` is the canonical, language-neutral fixture for the -AgentID public-key registration and approval protocol. Backend and SDK suites -should consume this file directly (or vendor it byte-for-byte with its version) -instead of retyping the values. - -The fixture pins: - -- the strict public P-256 JWK and its RFC 7638 SHA-256 thumbprint; -- `api_key_id` as the approval assertion `kid`; -- the exact `ES256` / `agentid-approval+jwt` protected header; -- the exact `{jti, inbox_id}` payload and one valid compact JWS; -- cryptographically valid semantic failures and signature-breaking mutations. - -The private JWK is intentionally public test material. Never register or use it -outside conformance tests. Run `npm run fixtures:check` to verify every vector -offline with Node's standard crypto implementation. diff --git a/conformance/agentid-key-auth-v1.json b/conformance/agentid-key-auth-v1.json deleted file mode 100644 index 2ac5cc6..0000000 --- a/conformance/agentid-key-auth-v1.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "version": 1, - "description": "AgentID P-256 thumbprint and compact approval assertion conformance vectors. The private key is test-only and must never be used for a real credential.", - "key": { - "private_jwk_test_only": { - "kty": "EC", - "crv": "P-256", - "x": "axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY", - "y": "T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", - "d": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE" - }, - "public_jwk": { - "kty": "EC", - "crv": "P-256", - "x": "axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY", - "y": "T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU" - }, - "rfc7638_sha256_thumbprint": "xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M" - }, - "credential": { - "api_key_id": "55555555-5555-4555-8555-555555555555" - }, - "approval": { - "protected_header": { - "alg": "ES256", - "typ": "agentid-approval+jwt", - "kid": "55555555-5555-4555-8555-555555555555" - }, - "payload": { - "jti": "Zm9vYmFyMTIzNDU2Nzg5MA", - "inbox_id": "agent@example.com" - }, - "compact_assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", - "request_body": { - "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", - "inbox_id": "agent@example.com" - } - }, - "rejected_assertions": [ - { - "name": "wrong_typ", - "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU1NTU1NTU1LTU1NTUtNDU1NS04NTU1LTU1NTU1NTU1NTU1NSJ9.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.kxkutVjbyc0LC0RPxmGjBsCiUrCq-EcKJBh_hJthRFVih3pM6l-SdEg_BE_twLUdC5hLKorzl2WYRD3h1me9Zg", - "signature_valid": true, - "expected_status": 401, - "reason": "The protected typ is not agentid-approval+jwt." - }, - { - "name": "extra_claim", - "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMH0.DcUb9BgF9eD_joB4PhT23W22mf50dKphuVrupjTpvkudsCTYODRC-oDEHCznTYNlTXt84IW1M_YQCaNIPptviQ", - "signature_valid": true, - "expected_status": 400, - "reason": "The signed payload contains iat in addition to jti and inbox_id." - }, - { - "name": "tampered_header", - "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1IiwiY3JpdCI6WyJleHAiXX0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJhZ2VudEBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", - "signature_valid": false, - "expected_status": 401, - "reason": "The signed protected header was modified and also carries forbidden crit." - }, - { - "name": "tampered_payload", - "assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImFnZW50aWQtYXBwcm92YWwrand0Iiwia2lkIjoiNTU1NTU1NTUtNTU1NS00NTU1LTg1NTUtNTU1NTU1NTU1NTU1In0.eyJqdGkiOiJabTl2WW1GeU1USXpORFUyTnpnNU1BIiwiaW5ib3hfaWQiOiJvdGhlckBleGFtcGxlLmNvbSJ9.fc-3ptt9xSmmmrNliySWUzOZb6SGiGYIlEjOkDFieIqcWSWqIxHDvTgkVbiMtS6Wg7949BUltOSZ54qgQWbH_A", - "signature_valid": false, - "expected_status": 401, - "reason": "The signed inbox_id was modified without a new signature." - } - ] -} diff --git a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx index 33704a8..440fb59 100644 --- a/fern/changelog/2026-07-20-agentid-public-key-auth.mdx +++ b/fern/changelog/2026-07-20-agentid-public-key-auth.mdx @@ -7,7 +7,7 @@ tags: ["api-keys", "agentid", "new-feature", "sdk"] 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 plus shared conformance vectors. +and TypeScript approval helpers. ### What's new? @@ -27,7 +27,6 @@ and TypeScript approval helpers plus shared conformance vectors. - **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. -- **Portable conformance**: Verify RFC 7638 thumbprints and compact JWS acceptance and rejection across backend and SDK implementations. ### Use cases diff --git a/fern/pages/guides/agentid-public-key-authentication.mdx b/fern/pages/guides/agentid-public-key-authentication.mdx index 476a1e8..562058d 100644 --- a/fern/pages/guides/agentid-public-key-authentication.mdx +++ b/fern/pages/guides/agentid-public-key-authentication.mdx @@ -411,12 +411,3 @@ 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. - -## Conformance vectors - -The repository publishes the versioned -[`agentid-key-auth-v1.json`](https://github.com/agentmail-to/agentmail-docs/blob/main/conformance/agentid-key-auth-v1.json) -fixture for backend and SDK suites. It includes a test-only P-256 private key, -public JWK, RFC 7638 thumbprint, valid compact assertion, a validly signed wrong -`typ`, a validly signed extra claim, and signature-breaking header and payload -mutations. Never use its private key for a real credential. diff --git a/package.json b/package.json index 2b561d9..24ca034 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "docs:preview": "tsx bin/agentmail-docs.ts preview", "docs:publish": "tsx bin/agentmail-docs.ts publish", - "fixtures:check": "node scripts/validate-agentid-key-auth-fixtures.ts", "lint": "tsc --noEmit", "format": "prettier --write \"bin/**/*.ts\" package.json tsconfig.json" }, diff --git a/scripts/validate-agentid-key-auth-fixtures.ts b/scripts/validate-agentid-key-auth-fixtures.ts deleted file mode 100644 index fa5e5ac..0000000 --- a/scripts/validate-agentid-key-auth-fixtures.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { - createHash, - createPrivateKey, - createPublicKey, - verify, -} from "node:crypto"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -type Jwk = { - kty: "EC"; - crv: "P-256"; - x: string; - y: string; - d?: string; -}; - -type Fixture = { - version: number; - key: { - private_jwk_test_only: Jwk; - public_jwk: Jwk; - rfc7638_sha256_thumbprint: string; - }; - credential: { api_key_id: string }; - approval: { - protected_header: Record; - payload: Record; - compact_assertion: string; - request_body: { assertion: string; inbox_id: string }; - }; - rejected_assertions: Array<{ - name: string; - assertion: string; - signature_valid: boolean; - expected_status: number; - reason: string; - }>; -}; - -const fixturePath = join( - dirname(fileURLToPath(import.meta.url)), - "..", - "conformance", - "agentid-key-auth-v1.json", -); -const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; - -const fail = (message: string): never => { - throw new Error(message); -}; -const equal = (actual: unknown, expected: unknown, message: string) => { - if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(message); -}; -const decode = (segment: string) => - JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as Record< - string, - unknown - >; -const parts = (assertion: string) => { - const value = assertion.split("."); - if (value.length !== 3 || value.some((segment) => segment.length === 0)) { - fail("assertion is not a compact JWS"); - } - return value as [string, string, string]; -}; - -const publicJwk = fixture.key.public_jwk; -equal( - Object.keys(publicJwk), - ["kty", "crv", "x", "y"], - "public JWK must expose exactly kty, crv, x, and y", -); -if ( - publicJwk.kty !== "EC" || - publicJwk.crv !== "P-256" || - !/^[A-Za-z0-9_-]{43}$/.test(publicJwk.x) || - !/^[A-Za-z0-9_-]{43}$/.test(publicJwk.y) -) { - fail("public JWK is not a strict P-256 key"); -} - -const canonicalJwk = JSON.stringify({ - crv: publicJwk.crv, - kty: publicJwk.kty, - x: publicJwk.x, - y: publicJwk.y, -}); -const thumbprint = createHash("sha256") - .update(canonicalJwk) - .digest("base64url"); -if (thumbprint !== fixture.key.rfc7638_sha256_thumbprint) { - fail("RFC 7638 thumbprint does not match"); -} - -const derivedPublic = createPublicKey( - createPrivateKey({ key: fixture.key.private_jwk_test_only, format: "jwk" }), -).export({ format: "jwk" }); -equal( - { - kty: derivedPublic.kty, - crv: derivedPublic.crv, - x: derivedPublic.x, - y: derivedPublic.y, - }, - publicJwk, - "test private JWK does not derive the public JWK", -); - -const verifyCompact = (assertion: string) => { - const [header, payload, signature] = parts(assertion); - return verify( - "sha256", - Buffer.from(`${header}.${payload}`), - { - key: createPublicKey({ key: publicJwk, format: "jwk" }), - dsaEncoding: "ieee-p1363", - }, - Buffer.from(signature, "base64url"), - ); -}; - -const [header, payload] = parts(fixture.approval.compact_assertion); -equal( - decode(header), - fixture.approval.protected_header, - "valid protected header changed", -); -equal( - decode(payload), - fixture.approval.payload, - "valid signed payload changed", -); -equal( - fixture.approval.protected_header, - { - alg: "ES256", - typ: "agentid-approval+jwt", - kid: fixture.credential.api_key_id, - }, - "protected header is not exactly the AgentID contract", -); -equal( - Object.keys(fixture.approval.payload), - ["jti", "inbox_id"], - "approval payload must contain exactly jti and inbox_id", -); -if (!verifyCompact(fixture.approval.compact_assertion)) { - fail("valid compact assertion signature failed verification"); -} -if ( - fixture.approval.request_body.assertion !== fixture.approval.compact_assertion -) { - fail("request body does not carry the valid assertion"); -} -if ( - fixture.approval.request_body.inbox_id !== fixture.approval.payload.inbox_id -) { - fail("unsigned inbox_id is not byte-for-byte equal to the signed claim"); -} - -const expectedRejections = { - wrong_typ: true, - extra_claim: true, - tampered_header: false, - tampered_payload: false, -}; -equal( - Object.fromEntries( - fixture.rejected_assertions.map((vector) => [ - vector.name, - vector.signature_valid, - ]), - ), - expectedRejections, - "rejection vector inventory changed", -); -for (const vector of fixture.rejected_assertions) { - if (verifyCompact(vector.assertion) !== vector.signature_valid) { - fail(`${vector.name} signature expectation does not match`); - } - if (!vector.reason || ![400, 401].includes(vector.expected_status)) { - fail(`${vector.name} does not describe its expected rejection`); - } -} - -const rejected = Object.fromEntries( - fixture.rejected_assertions.map((vector) => [vector.name, vector]), -); -const [wrongTypHeader, wrongTypPayload] = parts(rejected.wrong_typ.assertion); -equal( - decode(wrongTypHeader), - { alg: "ES256", typ: "JWT", kid: fixture.credential.api_key_id }, - "wrong_typ must be validly signed with the wrong typ only", -); -equal( - decode(wrongTypPayload), - fixture.approval.payload, - "wrong_typ payload changed", -); - -const [extraClaimHeader, extraClaimPayload] = parts( - rejected.extra_claim.assertion, -); -equal( - decode(extraClaimHeader), - fixture.approval.protected_header, - "extra_claim protected header changed", -); -equal( - decode(extraClaimPayload), - { ...fixture.approval.payload, iat: 1_700_000_000 }, - "extra_claim must add only iat to the exact payload", -); - -const [tamperedHeader] = parts(rejected.tampered_header.assertion); -equal( - decode(tamperedHeader), - { ...fixture.approval.protected_header, crit: ["exp"] }, - "tampered_header must add forbidden crit without resigning", -); - -const [, tamperedPayload] = parts(rejected.tampered_payload.assertion); -equal( - decode(tamperedPayload), - { ...fixture.approval.payload, inbox_id: "other@example.com" }, - "tampered_payload must change only inbox_id without resigning", -); - -console.log(`AgentID key-auth fixture v${fixture.version} is valid.`);