Skip to content

Security: ThirdKeyAI/AgentPin

Security

docs/security.md

Security Best Practices

This guide covers the security model, threat mitigations, and operational best practices for AgentPin deployments.


Cryptographic Foundation

AgentPin uses ES256 (ECDSA with P-256) exclusively. All other algorithms are rejected.

Property Value
Signing Algorithm ECDSA (Elliptic Curve Digital Signature Algorithm)
Curve P-256 (secp256r1)
Hash SHA-256
Key Format JWK (RFC 7517) for public keys, PEM for private keys
Credential Format JWT (compact serialization)
Signature Encoding DER-encoded (cross-language compatible)

Why ES256 Only

Single-algorithm enforcement prevents:

  • Algorithm confusion attacks — Attacker substitutes none or HS256 to bypass verification
  • Downgrade attacks — Attacker forces weaker algorithm selection
  • Implementation complexity — Fewer code paths means fewer bugs

All four implementations (Rust, Go, JavaScript, Python) validate alg: "ES256" and typ: "agentpin-credential+jwt" before any other processing, and then validate the claim shape (MALFORMED_CREDENTIAL) before any cryptography.

Domain-separated signatures

Every object AgentPin signs other than the credential JWT itself — challenge responses, delegation attestations, A2A AgentCards, signed revocation documents and signed trust bundles — is signed over a domain-separated input:

<context> || 0x00 || (u32_be(len(field)) || field)*

with a fixed context label per object type (agentpin-response-v1, agentpin-attestation-v1, agentpin-agentcard-v1, agentpin-revocation-v1, agentpin-bundle-v1). The JWT keeps its RFC 7515 header.payload input, whose alphabet can never contain the NUL byte that terminates every context label, so a signature produced for one object type can never be replayed as another. In particular, answering a mutual-authentication challenge can never yield a credential signature: the responder also refuses any nonce that is not exactly 22 canonical base64url characters.


Key Management

Private Key Security

  • Never commit private keys to version control
  • Never embed private keys in application code or environment variables in plaintext
  • Store private keys in secure storage: file system with restricted permissions, HSM, or secrets manager
  • Use separate keys for separate environments (dev, staging, production)
# keygen creates private keys with mode 0600; verify after copying them elsewhere
ls -la ./keys/*.private.pem
# -rw------- 1 agentpin agentpin 227 Feb 15 2026 example-2026-01.private.pem

Key Rotation

Rotate keys regularly. Recommended schedule:

Key Type Rotation Period
Production signing keys Every 6-12 months
Development/testing keys Every 3 months
Keys after suspected compromise Immediately

Rotation procedure:

  1. Generate new key pair (agentpin keygen creates the private key with mode 0600 and refuses to overwrite an existing key unless --force)
  2. Add new public key to discovery document
  3. Reduce discovery document cache TTL during transition
  4. Begin issuing credentials with new key
  5. After grace period, revoke old key
  6. Remove old key from discovery document

Key ID Naming Convention

Use descriptive, time-stamped key IDs for traceability:

{domain}-{year}-{sequence}

Examples:
  example-2026-01       # First key for 2026
  example-2026-02       # Second key (after rotation)
  staging-2026-01       # Staging environment key

Credential Security

Short-Lived Credentials

Issue credentials with the shortest practical TTL:

Use Case Recommended TTL
Single API call 300 seconds (5 min)
Session 3600 seconds (1 hour)
Long-running task 14400 seconds (4 hours)
Maximum allowed 86400 seconds (24 hours)
# Prefer short TTLs
credential = issue_credential(
    private_key_pem=key,
    kid="example-2026-01",
    issuer="example.com",
    agent_id="urn:agentpin:example.com:agent",
    audience="verifier.com",
    capabilities=[Capability.create("read", "data")],
    ttl_secs=3600,  # 1 hour — not 86400
)

Audience Binding

Always specify an audience (aud claim) to prevent credential replay at unintended verifiers:

const credential = issueCredential(
    privateKey, kid, 'example.com',
    'urn:agentpin:example.com:agent',
    'verifier.com',  // Bind to specific verifier
    capabilities, null, null, 3600,
);

Verifiers should check the audience:

const result = verifyCredentialOffline(
    credential, discovery, null, pinStore,
    'verifier.com',  // Reject if aud doesn't match
);

Capability Scoping

Follow the principle of least privilege — issue credentials with only the capabilities needed:

# Good: Minimal capabilities for the task
credential = issue_credential(
    ...,
    capabilities=[Capability.create("read", "reports")],
    ttl_secs=300,
)

# Bad: Overly broad capabilities
credential = issue_credential(
    ...,
    capabilities=[Capability.create("read", "*"), Capability.create("write", "*")],
    ttl_secs=86400,
)

TOFU Key Pinning

Trust-On-First-Use pinning protects against key substitution attacks after initial verification.

How It Works

  1. First verification for a domain: public key fingerprint (JWK thumbprint per RFC 7638) is stored under the normalized domain name (lowercase, trailing dot removed, IDNA A-labels) — EXAMPLE.COM and example.com share one pin
  2. Subsequent verifications: key must match the stored fingerprint
  3. Key change detected: verification fails with KEY_PIN_MISMATCH

Pin Store Persistence

Always persist the pin store between sessions:

import os
from agentpin import KeyPinStore

PIN_FILE = "/var/lib/agentpin/pins.json"

# Load existing pins
if os.path.exists(PIN_FILE):
    pin_store = KeyPinStore.from_json(open(PIN_FILE).read())
else:
    pin_store = KeyPinStore()

# Use for verification
result = verify_credential_offline(jwt, discovery, None, pin_store, audience)

# Save updated pins
with open(PIN_FILE, "w") as f:
    f.write(pin_store.to_json())

Handling Key Changes

A KEY_PIN_MISMATCH failure requires investigation:

if not result.valid and result.error_code == ErrorCode.KEY_PIN_MISMATCH:
    # This is a security event — do not silently accept
    log_security_event(
        event="key_pin_mismatch",
        domain=payload_issuer,
        action="verification_rejected",
    )
    # Require manual approval before accepting the new key

On success result.key_pinning is an object: {"status": "first_use" | "pinned", "first_seen": ...}.

Legitimate key changes (rotation) should be communicated out-of-band. Update the pin store explicitly:

const pinStore = KeyPinStore.fromJson(existingPins);
// After confirming legitimate rotation:
pinStore.addKey(domain, newJwk);

Threat Model

Agent Impersonation

Threat: A malicious agent claims to be a trusted agent.

Mitigation: Cryptographic verification — the agent must present a JWT signed by a key declared in the issuer's discovery document. Without the private key, impersonation is impossible.

Unauthorized Delegation

Threat: An agent claims authorization from an operator who never granted it.

Mitigation: Delegation chains require a Maker attestation — a cryptographic signature from the Maker over the domain-separated attestation input (attester domain and key id, role, agent id, delegatee domain and agent id, capabilities hash, optional expiry). Every verifier resolves the attester's discovery document, verifies the signature, checks that the credential's capabilities stay within what the attester declared for the agent, and bounds the chain depth. A chain that cannot be verified is rejected by default (require_delegation_verification); the Deployer cannot forge an attestation without the Maker's private key.

Capability Inflation

Threat: An agent claims capabilities beyond what it was authorized.

Mitigation: Capability validation (Step 10) checks that credential capabilities are a subset of capabilities declared in the discovery document. Delegation chains enforce capability narrowing at each level.

Discovery Document Tampering

Threat: An attacker modifies the discovery document in transit or at the server.

Mitigation:

  • HTTPS provides transport-layer integrity
  • TOFU pinning detects key changes after initial verification
  • Redirect rejection prevents redirect-based attacks
  • revocation_endpoint and a2a_endpoint must be https:// URLs on the entity's own host; anything else makes the document invalid

Replay Attacks

Threat: An attacker captures and replays a valid credential.

Mitigation:

  • Short-lived credentials minimize the replay window
  • Audience binding prevents replay at unintended verifiers
  • JTI (JWT ID) enables one-time-use verification
  • Nonce-based mutual authentication for real-time verification

Key Compromise

Threat: An attacker obtains a private key.

Mitigation:

  • Revocation at credential, agent, and key levels
  • Short credential TTLs limit exposure window; verifiers enforce both their own cap and the agent's declared credential_ttl_max
  • TOFU pinning detects key substitution at verifiers that already pinned the legitimate key
  • KeyPair zeroizes the private PEM on drop and redacts it from Debug output (Rust)

Revocation

Three Levels of Revocation

Level Scope Use Case
Credential Single JWT (by jti) Compromised individual credential
Agent All credentials for an agent Agent decommissioned or compromised
Key All credentials signed by a key Key compromised

Revocation Document

Publish at /.well-known/agent-identity-revocations.json with a short cache TTL (5 minutes):

{
  "agentpin_version": "0.1",
  "entity": "example.com",
  "revoked_credentials": [
    { "jti": "jti-123", "reason": "key_compromise", "revoked_at": "2026-02-15T00:00:00Z" }
  ],
  "revoked_agents": [
    { "agent_id": "urn:agentpin:example.com:retired-agent", "reason": "cessation_of_operation", "revoked_at": "2026-02-15T00:00:00Z" }
  ],
  "revoked_keys": [
    { "kid": "example-2025-01", "reason": "key_compromise", "revoked_at": "2026-02-15T00:00:00Z" }
  ],
  "updated_at": "2026-02-15T00:00:00Z"
}

The identifying fields are jti (credentials), agent_id (agents) and kid (keys), exactly as in spec §8.2. Every SDK validates the document structurally before using it: an entry that lacks its identifying field makes the whole document invalid and the credential is rejected with DISCOVERY_INVALID, so a mis-published schema can never silently disable revocation.

Optionally sign the document with one of the discovery-document keys (sign_revocation_document / signRevocationDocument / SignRevocationDocument); it then carries kid and signature fields and every verifier checks the signature against the discovery keys. Verifiers can require this with require_signed_revocation and bound staleness with revocation_max_age_secs.

Revocation Checking

Verifiers MUST check revocation on every verification, regardless of discovery document cache state. When the discovery document declares no revocation_endpoint, verifiers fetch the default https://{domain}/.well-known/agent-identity-revocations.json; a 404 there means no revocations are published, any other failure rejects the credential (fail-closed).


Network Security

HTTPS Requirements

  • Discovery documents MUST be served over HTTPS
  • TLS certificates MUST be valid (not expired, not self-signed in production)
  • HTTP redirects MUST NOT be followed during discovery, revocation or AgentCard fetching
  • Every fetch is bounded: 10 s timeout, 1 MiB body cap (both by Content-Length and while streaming), JSON Content-Type required
  • iss is normalized and must be a bare hostname: a value with a scheme, port, path, userinfo or IP literal is rejected before any request is sent

No-Redirect Policy

AgentPin rejects HTTP redirects to prevent:

  • Redirect to attacker-controlled server serving malicious discovery document
  • Redirect loops causing denial of service
  • Open redirect exploitation
// Correct: reject redirects
const response = await fetch(url, { redirect: 'error' });

// Incorrect: following redirects
const response = await fetch(url, { redirect: 'follow' }); // DO NOT DO THIS

Rate Limiting

Protect discovery endpoints from abuse:

# nginx rate limiting for AgentPin endpoints
limit_req_zone $binary_remote_addr zone=agentpin:10m rate=10r/s;

location /.well-known/agent-identity.json {
    limit_req zone=agentpin burst=20 nodelay;
    # ... other config
}

Cross-Language Interoperability

All four AgentPin implementations (Rust, Go, JavaScript, Python) produce interoperable credentials:

  • All use DER-encoded ECDSA signatures (deliberately not RFC 7515 raw r||s; see spec §5.1)
  • All use identical JSON field names and strict, unpadded base64url
  • A credential issued by one language can be verified by any other
  • The shared testdata/conformance/ vectors are loaded by all four test suites and include must-reject cases

Verify cross-language compatibility in your test suite:

# Issue with Python, verify with JavaScript
python -c "from agentpin import ...; print(issue_credential(...))" | \
  node -e "const { verifyCredentialOffline } = require('agentpin'); ..."

There aren't any published security advisories