@agenticprimitives/key-custody
Part of Agentic Primitives — the open-source trust substrate for agentic applications: identity that can sign, authority checked at act time, evidence the owner carries. Developer kit · All packages
Custody is not authority.
In this stack, keys sign — they do not decide. Authority lives in delegations and custody policy; this package is the cryptographic floor underneath them: pluggable envelope encryption, KMS-backed signers, and HMAC providers behind one A2AKeyProvider interface. Local-AES and a local secp256k1 signer for development, AWS KMS and GCP KMS for real deployments. Because a signer here is just a signer, a compromised session key is a revocation event, not an identity loss — the Smart Agent address and its custody policy sit above the key, not inside it.
Narrower than you might expect, on purpose: session lifecycle is owned by @agenticprimitives/delegation. This package provides the crypto primitives; delegation's SessionManager wires them (ADR-0002). Payloads are opaque bytes with caller-supplied AAD — bound identically into AES-GCM AAD and KMS EncryptionContext, so tampering trips both.
Part of agenticprimitives — the trust substrate for the agent economy: one canonical Smart Agent identity with custody, delegation, naming, credentials, and audit evidence designed as one system.
See spec.md → specs/203-key-custody.md.
Quick start
import { buildKeyProvider, buildSignerBackend, createKmsAccount, canonicalContextBytes } from '@agenticprimitives/key-custody';
const provider = buildKeyProvider({ backend: process.env.A2A_KMS_BACKEND });
// Generate a wrapped data key for envelope encryption
const { plaintextDataKey, encryptedDataKey, keyId, keyVersion } =
await provider.generateSessionDataKey({ aadContext: { /* caller-supplied */ } });
// Sign as a viem-compatible signer
const signer = buildSignerBackend({ backend: 'aws-kms' });
const kmsAccount = await createKmsAccount(signer);
Beyond the basics: per-OIDC-subject signer derivation via HKDF (deriveSubjectSigner, spec 235), a relay-only master signer that throws on any signing call (getRelayOnlySigner), and HMAC providers under the /mac subpath. Every decrypt and signing operation emits an audit row with keyVersion and a hashed session ID — the raw session ID is never logged.
agentic-kms — the Agentic Key Custody Service backend
backend: 'agentic-kms' routes envelope data keys, MAC, generated-key signing and per-subject
derived signers to an AKCS deployment (HSM-rooted, confidential-VM custody; repo faithkms).
The adapter is fetch + Web Crypto only — no cloud SDK — and keeps every package rule: secrets are
Uint8Array, private keys never exist in this process, and there is no fallback to local-aes
or gcp-kms on a remote error.
import { buildKeyProvider, deriveSubjectSigner, staticTokenProvider, loadSecret } from '@agenticprimitives/key-custody';
const agenticKms = {
baseUrl: 'https://akcs.example.internal',
tenantId: 'my-tenant',
tokenProvider: () => getWorkloadToken(), // short-lived JWT for the AKCS audience
// dev only: staticTokenProvider(loadSecret(env.AKCS_TOKEN))
acceptedProtectionLevels: ['CVM_DERIVED', 'CVM_WRAPPED'], // add 'DEV_LOCAL' only in dev
};
const provider = buildKeyProvider({ backend: 'agentic-kms', agenticKms });
const signer = deriveSubjectSigner({ backend: 'agentic-kms', agenticKms, subject: { iss, sub } });
The exact ap-oidc-custodian-secp256k1-v1 derivation runs inside AKCS; a legacy
A2A_MASTER_PRIVATE_KEY/custody root is imported there through a verified operator ceremony so
existing addresses are preserved. Cross-language vectors live in test/vectors/.
Production guard
local-aes refuses to boot when NODE_ENV=production. AWS, GCP and agentic-kms backends have no local fallback — they fail-closed on outage. This is intentional: a key-custody layer that silently downgrades to a weaker backend is not a custody layer.
How it's different
Turnkey and Fireblocks are strong custody products: managed key infrastructure with their own policy engines, consoles, and trust models. They are also where your authority model ends up living — the policy that governs a key is the vendor's policy, expressed in the vendor's terms. This package takes the opposite position: KMS backends are interchangeable plumbing (buildKeyProvider({ backend })), and the authority model lives one layer up, in on-chain custody policy and EIP-712 delegations that the rest of the substrate enforces and audits. You keep cloud-KMS-grade key protection without making a vendor's policy engine your source of truth for who may do what.
Validate
pnpm --filter @agenticprimitives/key-custody typecheck
pnpm --filter @agenticprimitives/key-custody test
Status
Testnet/pilot-ready. Production launch is gated on the public checklist in the root README — including third-party contract audit and governance key rotation. Track every security finding live in docs/audits/findings.yaml.
The P-256 signer port
JwsSignerPort is the second signer port: ES256 over SHA-256 of a message, raw r || s, low-S, kid = the RFC 7638
thumbprint — for keys the field verifies (a JWS, an OpenAC device binding, a credential issuer). LocalEs256Signer is
the dev/test double. In production the key lives in the agentic-kms key service and createAkcsEs256Signer binds it:
- A device key (
signingPurpose: 'ES256_SHA256', the default) signs a presentation only under bound evidence — a token naming the digest, the venue and the predicate digest, minted by@agenticprimitives/delegation's presentation release after it judged the person'scredential.presentwire. - An issuing key (
signingPurpose: 'ES256_ISSUE_SHA256') signs a credential only under issuance evidence — a token naming the digest, the issuer facet and the credential type.
A device key cannot issue and an issuing key cannot present: the key service refuses the other purpose. Every answer
is verified against the key's published point before it is returned. createAkcsP256Key provisions such a key.
derivePairwiseSubject gives the person one stable, unlinkable subject per company or service (usr_ + HKDF over a
secret in her vault, salted by the audience), and ExternalAccountLinkV1 records what her agent holds at one company.
Reference
Exports. Interface: A2AKeyProvider, KmsAccountBackend, KmsBackend, BuildOpts. Factories: buildKeyProvider,
buildSignerBackend, buildMacProvider, getRelayOnlySigner, buildToolExecutorBackendNoIsolation (dev only, refused in
production); buildToolExecutorBackend is deprecated and throws (it ignored toolId and returned the master signer).
Per subject (spec 235): deriveSubjectSigner, subjectCanonicalMessage, SubjectId, DeriveSubjectOpts. viem adapter:
createKmsAccount. Built-ins: LocalAesProvider, LocalSecp256k1Signer, GcpKmsProvider, GcpKmsSigner,
RemoteAgenticKmsProvider, RemoteAgenticKmsSigner, remoteSubjectSigner, staticTokenProvider (dev fixtures),
AgenticKmsError. ES256: JwsSignerPort, LocalEs256Signer (dev/test; refuses production without
A2A_ALLOW_LOCAL_DEVICE_KEY=true), p256JwkThumbprint, verifyEs256, AkcsEs256Signer, createAkcsEs256Signer,
createAkcsP256Key. Spec 442: derivePairwiseSubject, ExternalAccountLinkV1, sessionStartDecision,
revokeExternalAccountLink, signInKindRefusal, securityRowOf. AAD: canonicalContextBytes. Not exported:
deriveSubjectPrivateKeyHex (module-internal), and the AWS provider and signer (unimplemented stubs that throw).
The consumer-safe KMS core (./kms-core, spec 276). src/kms/{secp256k1-core,gcp-transport,kms-core}.ts is the
signing surface with no peer dependencies (only @noble/*). External apps import signDigestWithKms, gcpSignDigest,
createGcpKmsTransport and addressFromSpkiPem from @agenticprimitives/key-custody/kms-core instead of inlining a KMS
signer. GcpKmsSigner is a thin audited wrapper over it; the crypto stays in the core. test/unit/kms-core.test.ts
guards the import graph.
The agentic-kms adapter. RemoteAgenticKmsProvider (envelope + MAC) and RemoteAgenticKmsSigner (generated keys and
per-subject derived signers) in src/providers/agentic.ts use fetch and Web Crypto only, take a just-in-time
TokenProvider, and never fall back. The ap-oidc-custodian-secp256k1-v1 derivation runs server side; test/vectors/*.json
are golden vectors shared with the key service. A response outside acceptedProtectionLevels is refused.
Audit. key-custody.sign from the local and GCP signers and the ES256 signer; key-custody.envelope.{wrap,unwrap} from
GcpKmsProvider, with the AAD hashed and the session id hashed (keccak256(sessionId), first 18 hex), never raw.
Common tasks. A new KMS backend implements A2AKeyProvider in src/providers/<backend>.ts, registers in
buildKeyProvider and adds a subpath export. A new audit field goes in src/audit.ts, coordinated with delegation.
A crypto change needs a security review, the AAD invariants above all. Spec of record: specs/203-key-custody.md.