layr0 is an agent-to-agent communication transport where every agent's identity is an Ed25519 did:key. When our agents encounter a service implementing auth.md, they can prove who they are cryptographically, but the spec has no registration path that accepts that proof.
This proposal adds did_key as a new entry in identity_types_supported, using Ed25519 challenge-response. It is purely additive, introduces no new token format or grant type, and requires no external infrastructure to verify.
The gap
identity_types_supported currently covers:
| Type |
Agent proves |
Requires |
Trust anchor |
anonymous |
Nothing |
Nothing |
None |
identity_assertion + id-jag |
Provider vouches |
Provider + JWKS |
Provider reputation |
identity_assertion + verified_email |
Inbox access |
Email + OTP |
Email provider |
These are useful modes. But there is a class of agent between "anonymous" and "provider-backed" that has no path today:
- Autonomous agents with Ed25519 keypairs and no provider
- DID-based systems (AT Protocol, UCAN, peer-to-peer protocols)
- CI/CD agents with generated keypairs but no human inbox
These agents can produce cryptographic proof of identity that is at least as strong as an ID-JAG assertion (direct proof-of-possession of a signing key, without indirection through a provider). The spec could accept that proof.
Proposed identity type: did_key
Discovery
Services that support did_key advertise it in /.well-known/oauth-authorization-server:
{
"agent_auth": {
"identity_types_supported": ["anonymous", "identity_assertion", "did_key"],
"did_key": {
"methods_supported": ["ed25519"],
"credential_types_supported": ["access_token", "api_key"],
"challenge_endpoint": "/agent/auth/challenge"
}
}
}
Registration flow
Step 1: Server issues a challenge.
GET /agent/auth/challenge
{
"challenge": "<base64url, ≥32 bytes from CSPRNG>",
"expires_at": "2026-05-24T12:05:00Z"
}
The challenge must be server-generated, cryptographically random, short-lived (60s recommended, 300s max), and single-use. The verifier controls the replay boundary.
Step 2: Agent proves key possession.
POST /agent/auth
Content-Type: application/json
{
"type": "did_key",
"did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"challenge": "<echoed>",
"signature": "<base64url Ed25519 signature over UTF-8 challenge bytes>",
"requested_credential_type": "api_key"
}
Step 3: Server verifies and issues credential.
- Validate the challenge exists, is unexpired, and has not been redeemed
- Parse the
did:key (strip prefix, base58btc-decode, check 0xed01 multicodec prefix)
- Extract the 32-byte Ed25519 public key
- Verify the signature over the exact challenge bytes
- Invalidate the challenge
- Issue a credential scoped to the DID according to local policy
{
"registration_id": "reg_...",
"registration_type": "did_key",
"credential_type": "api_key",
"credential": "...",
"scopes": ["api.read", "api.write"],
"did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
}
Why challenge-response, not a signed JWT
A did:key string serves as the public key (multicodec-encoded, base58btc multibase). Challenge-response serves as the natural authentication pattern when the verifier already has the key and just needs proof of private key control. An agent whose public key is already encoded in its identifier doesn't need any of that:
- No JWKS infrastructure or remote key fetch
- No JOSE header validation,
kid selection, or issuer-to-JWKS trust chain
- No
exp/iat clock-skew handling
- No token parsing complexity
The DID contains the key, signature proves possession, and the server should prevent nonce replay which is the full verification surface.
This also means did_key does not expand custom protocol surface. It uses the existing /agent/auth endpoint with a new type discriminant and a companion challenge endpoint. No new grant type, no new token format.
Security properties
| Concern |
Mitigation |
| Replay |
Server-generated nonce, single-use, short TTL. Strictly stronger than client-chosen jti. |
| Key authenticity |
Deterministic extraction from did:key multicodec encoding. No network fetch, no TOCTOU window. |
| External dependency |
Zero. No JWKS, no DNS, no DID-document resolution for did:key. |
| Clock skew |
Challenge expiry is server-authoritative. No client timestamps. |
| Algorithm confusion |
Scoped to Ed25519 did:key only. Other DID methods would be separate proposals. |
| Over-authorization |
Credential scopes remain entirely service-controlled. did_key proves identity, not authorization level. |
Compatibility
Purely additive. Services that do not implement did_key omit it from metadata. Existing identity types are unchanged.
Prior art
- W3C DID Core v1.1 (Foundational specification for Decentralized Identifiers)
- did:key Method Spec, W3C CCG (Multicodec encoding for self-describing key-based DIDs)
- UCAN (Authorization framework using DIDs as root identity primitives; Fission, Storacha)
- AT Protocol, Bluesky (DID-based identity at 20M+ accounts)
- RFC 8037 (Ed25519 and Ed448 in JOSE)
- Hypercore Protocol (Production use of Ed25519 as direct peer identity)
Open questions
-
Challenge endpoint. Should it be a dedicated GET route or a mode of the existing POST /agent/auth? A separate endpoint keeps the nonce lifecycle explicit and auditable.
-
Audience binding. Should the signed payload include the service origin (sign(challenge || origin))? This would add cross-service replay resistance at the cost of slightly more complexity.
-
DID method extensibility. Should other DID methods (did:web, did:plc) be included? We'd recommend did:key only for a first version (other methods require external resolution and introduce different trust assumptions that deserve separate evaluation).
-
Scope recommendation. Should the spec suggest a default scope tier for did_key, or leave it entirely to service policy?
We have a working Ed25519 did:key identity and UCAN authorization system in layr0-core and would be glad to contribute a reference implementation of the challenge-response verification flow.
layr0 is an agent-to-agent communication transport where every agent's identity is an Ed25519
did:key. When our agents encounter a service implementing auth.md, they can prove who they are cryptographically, but the spec has no registration path that accepts that proof.This proposal adds
did_keyas a new entry inidentity_types_supported, using Ed25519 challenge-response. It is purely additive, introduces no new token format or grant type, and requires no external infrastructure to verify.The gap
identity_types_supportedcurrently covers:anonymousidentity_assertion+id-jagidentity_assertion+verified_emailThese are useful modes. But there is a class of agent between "anonymous" and "provider-backed" that has no path today:
These agents can produce cryptographic proof of identity that is at least as strong as an ID-JAG assertion (direct proof-of-possession of a signing key, without indirection through a provider). The spec could accept that proof.
Proposed identity type:
did_keyDiscovery
Services that support
did_keyadvertise it in/.well-known/oauth-authorization-server:{ "agent_auth": { "identity_types_supported": ["anonymous", "identity_assertion", "did_key"], "did_key": { "methods_supported": ["ed25519"], "credential_types_supported": ["access_token", "api_key"], "challenge_endpoint": "/agent/auth/challenge" } } }Registration flow
Step 1: Server issues a challenge.
{ "challenge": "<base64url, ≥32 bytes from CSPRNG>", "expires_at": "2026-05-24T12:05:00Z" }The challenge must be server-generated, cryptographically random, short-lived (60s recommended, 300s max), and single-use. The verifier controls the replay boundary.
Step 2: Agent proves key possession.
Step 3: Server verifies and issues credential.
did:key(strip prefix, base58btc-decode, check0xed01multicodec prefix){ "registration_id": "reg_...", "registration_type": "did_key", "credential_type": "api_key", "credential": "...", "scopes": ["api.read", "api.write"], "did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" }Why challenge-response, not a signed JWT
A
did:keystring serves as the public key (multicodec-encoded, base58btc multibase). Challenge-response serves as the natural authentication pattern when the verifier already has the key and just needs proof of private key control. An agent whose public key is already encoded in its identifier doesn't need any of that:kidselection, or issuer-to-JWKS trust chainexp/iatclock-skew handlingThe DID contains the key, signature proves possession, and the server should prevent nonce replay which is the full verification surface.
This also means
did_keydoes not expand custom protocol surface. It uses the existing/agent/authendpoint with a newtypediscriminant and a companion challenge endpoint. No new grant type, no new token format.Security properties
jti.did:keymulticodec encoding. No network fetch, no TOCTOU window.did:key.did:keyonly. Other DID methods would be separate proposals.did_keyproves identity, not authorization level.Compatibility
Purely additive. Services that do not implement
did_keyomit it from metadata. Existing identity types are unchanged.Prior art
Open questions
Challenge endpoint. Should it be a dedicated
GETroute or a mode of the existingPOST /agent/auth? A separate endpoint keeps the nonce lifecycle explicit and auditable.Audience binding. Should the signed payload include the service origin (
sign(challenge || origin))? This would add cross-service replay resistance at the cost of slightly more complexity.DID method extensibility. Should other DID methods (
did:web,did:plc) be included? We'd recommenddid:keyonly for a first version (other methods require external resolution and introduce different trust assumptions that deserve separate evaluation).Scope recommendation. Should the spec suggest a default scope tier for
did_key, or leave it entirely to service policy?We have a working Ed25519
did:keyidentity and UCAN authorization system in layr0-core and would be glad to contribute a reference implementation of the challenge-response verification flow.