@identities-ai/ratify-protocol
v1.0.0-alpha.20
Published
Delegated-authority proofs for human-agent and agent-agent interactions — TypeScript reference SDK for the Ratify Protocol.
Maintainers
Readme
@identities-ai/ratify-protocol
TypeScript reference SDK of the Ratify Protocol v1 — delegated-authority proofs for human-agent and agent-agent interactions.
Quantum-safe by design: every signature is hybrid Ed25519 + ML-DSA-65 (NIST FIPS 204). Both must verify.
Byte-identical interoperability with the Go, Python, Rust, and C/C++ reference implementations. Validated against the 79 canonical test vectors on every CI run.
What is Ratify Protocol?
Ratify is an open cryptographic protocol that answers the question: "Is this AI agent authorized to act, by whom, for what, and under what constraints?"
A human issues a signed delegation cert to an agent. The agent presents a proof bundle when acting. Any third party can verify the proof — offline, without contacting a server — and get a cryptographically certain answer.
Beyond the one-shot delegate → present → verify round trip, this SDK implements the full v1.1 feature set for continuous and multi-party interactions: session-bound challenges and stream sequence numbers (replay and reorder detection across a multi-turn conversation), single-use challenge acceptance through a pluggable challenge store (SPEC §10), the SessionToken fast path (one hybrid signature verification per turn instead of N+1 — practical for live voice and video) with scope, single-use, and binding enforcement on the streamed path, canonical operation- and session-context binding for middleware custody deployments (SPEC §6.4.9, §15.2.1), push-based revocation, multi-party transaction receipts, witness append-only logs, and key rotation statements. All normative in the spec.
- Full protocol spec: SPEC.md
- Explainer (how it works, threat model): docs/EXPLAINED.md
- Developer docs: docs.identities.ai
Install
npm install @identities-ai/[email protected]Three verbs, three examples
1. DELEGATE — a human authorizes an agent
import {
generateHumanRoot,
generateAgent,
issueDelegation,
PROTOCOL_VERSION,
SCOPE_MEETING_ATTEND,
SCOPE_MEETING_SPEAK,
type DelegationCert,
} from "@identities-ai/ratify-protocol";
// Alice creates her root (once, ever)
const { root, privateKey: alicePriv } = await generateHumanRoot();
// Her agent has its own keypair
const { agent, privateKey: agentPrivateKey } = await generateAgent("Alice's Scheduler", "custom");
// Alice signs a delegation
const cert: DelegationCert = {
cert_id: crypto.randomUUID(),
version: PROTOCOL_VERSION,
issuer_id: root.id,
issuer_pub_key: root.public_key,
subject_id: agent.id,
subject_pub_key: agent.public_key,
scope: [SCOPE_MEETING_ATTEND, SCOPE_MEETING_SPEAK],
issued_at: Math.floor(Date.now() / 1000),
expires_at: Math.floor(Date.now() / 1000) + 7 * 24 * 3600, // 7 days
constraints: [],
signature: { ed25519: new Uint8Array(0), ml_dsa_65: new Uint8Array(0) }, // filled in by issueDelegation
};
await issueDelegation(cert, alicePriv);2. PRESENT — an agent builds a proof bundle
import {
signChallenge,
type ProofBundle,
} from "@identities-ai/ratify-protocol";
// Challenge comes from the verifier, over the wire
const challenge = /* received from verifier */ new Uint8Array(32);
const challengeAt = Math.floor(Date.now() / 1000);
const bundle: ProofBundle = {
agent_id: agent.id,
agent_pub_key: agent.public_key,
delegations: [cert],
challenge,
challenge_at: challengeAt,
challenge_sig: await signChallenge(challenge, challengeAt, agentPrivateKey),
};
// Send bundle (as canonical JSON) over HTTP / your transport3. VERIFY — any third party checks the proof
import { verifyBundle, SCOPE_MEETING_ATTEND } from "@identities-ai/ratify-protocol";
const result = await verifyBundle(bundle, {
required_scope: SCOPE_MEETING_ATTEND,
});
if (!result.valid) {
console.log("rejected:", result.identity_status, result.error_reason);
} else {
console.log("authorized agent:", result.agent_id, "for", result.human_id);
console.log("effective scope:", result.granted_scope);
}Key custody
The protocol supports three key-custody modes with different trust tradeoffs. See SPEC.md §15.2 for the full model.
Self-custody (strongest)
The user generates and holds their own keypair. No third party can sign on their behalf.
import { generateHumanRoot, issueDelegation } from "@identities-ai/ratify-protocol";
// User generates keypair on their own device — private key never leaves
const { root, privateKey } = await generateHumanRoot();
// User signs delegations locally
const cert = { /* ... */ };
await issueDelegation(cert, privateKey);
// Only the public root.id and root.public_key are shared with registriesCustodial
A registry operator generates and stores the keypair server-side (envelope-encrypted with KMS). The user never touches keys directly. The operator calls the same SDK functions on the user's behalf.
Self-custody upgrade
A user who started in custodial mode can migrate to self-custody at any time using KeyRotationStatement:
import {
generateHumanRoot,
issueKeyRotationStatement,
} from "@identities-ai/ratify-protocol";
// User generates a NEW keypair on their device
const { root: newRoot, privateKey: newPrivateKey } = await generateHumanRoot();
// Rotation statement signed by BOTH old (custodial) and new (device) keys
const stmt = {
version: 1,
old_id: oldRoot.id,
old_pub_key: oldRoot.public_key,
new_id: newRoot.id,
new_pub_key: newRoot.public_key,
rotated_at: Math.floor(Date.now() / 1000),
reason: "routine" as const,
signature_old: { ed25519: new Uint8Array(0), ml_dsa_65: new Uint8Array(0) },
signature_new: { ed25519: new Uint8Array(0), ml_dsa_65: new Uint8Array(0) },
};
await issueKeyRotationStatement(stmt, oldCustodialPrivateKey, newPrivateKey);
// From now on, only the user's device key can sign delegations.
// Auditors verify continuity via the rotation statement.Canonical serialization
Signed payloads follow Ratify's canonical JSON rules (see SPEC.md §6). The SDK exposes:
import { canonicalJSON, delegationSignBytes, challengeSignBytes } from "@identities-ai/ratify-protocol";These produce byte-identical output to the Go reference implementation. The test/conformance.test.ts suite runs the 79 published test vectors through the TS code and asserts byte-for-byte equivalence.
Wire transport
Signed Ratify structures travel as canonical JSON. The wire codec turns typed structures into those bytes and back, so integrators never hand-roll the base64 and byte-length handling.
Sending a proof bundle
import { encodeProofBundle } from "@identities-ai/ratify-protocol";
const body = encodeProofBundle(bundle); // canonical JSON string
await fetch("https://verifier.example.com/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body,
});Receiving a proof bundle
import { decodeProofBundle, verifyBundle, SCOPE_MEETING_ATTEND } from "@identities-ai/ratify-protocol";
const bundle = decodeProofBundle(requestBody); // string or Uint8Array
const result = await verifyBundle(bundle, { required_scope: SCOPE_MEETING_ATTEND });Session tokens
SessionTokens cross the wire the same way (DelegationCerts too, via encodeDelegationCert / decodeDelegationCert):
import { encodeSessionToken, decodeSessionToken } from "@identities-ai/ratify-protocol";
// Verifier issues the token after the first full verify and sends it out:
const tokenJSON = encodeSessionToken(token);
// The agent presents it on later turns; the verifier decodes and checks it:
const presented = decodeSessionToken(tokenJSON);Strict decoding
Decoders fail closed. A document is rejected — with an error naming the offending field — if it carries malformed UTF-8 or a byte-order mark; malformed or non-canonical base64; a wrong byte length for a key, signature, challenge, or 32-byte binding field; a missing or mistyped required field; an integer outside the IEEE-754 safe-integer range [-(2^53-1), 2^53-1] (SPEC §6.2); an empty delegation chain; unpaired stream fields; duplicated JSON object keys (at any nesting depth, with string escapes decoded before comparison); or an unknown field in a signed structure. Anything a conformant implementation would not have produced is treated as malformed at the transport boundary instead of surfacing later as a confusing verification failure.
The strictness applies to the signed structures themselves — ProofBundle, DelegationCert, SessionToken reject unknown fields because every byte of them is protocol surface. Your application's transport envelope is a different thing: it may carry whatever integration metadata you need, as long as that metadata stays outside the signed structure. For example, {"proof_bundle": {...}, "app_metadata": {...}} is fine — decode proof_bundle strictly with decodeProofBundle and treat the rest of the envelope as your own; a request_id inside the bundle itself would (correctly) be rejected.
Vocabulary discovery
Consoles and policy editors that present scope choices should derive them from the protocol rather than hardcoding strings, so UI vocabularies cannot drift:
import { vocabulary, scopeWildcards } from "@identities-ai/ratify-protocol";
vocabulary(); // all 54 canonical scopes, lex-sorted
scopeWildcards(); // wildcard shorthand -> non-sensitive member scopesScope vocabulary
import {
SCOPE_MEETING_ATTEND, // "meeting:attend"
SCOPE_FILES_WRITE, // sensitive — never rides a wildcard
expandScopes,
intersectScopes,
isSensitive,
validateScopes,
} from "@identities-ai/ratify-protocol";
expandScopes(["meeting:*"]);
// ["meeting:attend", "meeting:chat", "meeting:share_screen", "meeting:speak", "meeting:video"]
intersectScopes(["meeting:*"], ["meeting:attend", "meeting:speak"]);
// ["meeting:attend", "meeting:speak"]Ratify v1 ships 54 canonical scopes plus 14 wildcards and a custom: extension pattern for application-specific scopes. See SPEC.md §9 for the full table including sensitivity flags and wildcard expansions.
For app-specific needs not covered by the canonical vocabulary, use the custom: prefix:
import { CUSTOM_SCOPE_PREFIX, validateScopes } from "@identities-ai/ratify-protocol";
validateScopes(["custom:acme:inventory:read"]); // → null (valid)Custom scopes pass through expandScopes unchanged and are non-sensitive by default.
Running the conformance tests
From this SDK directory:
npm install
npm testThe conformance suite loads every fixture from the canonical test vectors and runs it through the TypeScript implementation. It checks:
- Canonical signing bytes match the committed hex for every cert
- Challenge signing bytes match
verifyBundleproduces the sameVerifyResultas the Go reference- Scope expansion is deterministic and matches
- Revocation list signatures verify
A single failure means TypeScript and the Go reference have drifted.
Security posture
- Ed25519 via @noble/ed25519 — audited, zero native deps, universal.
- ML-DSA-65 via @noble/post-quantum — NIST FIPS 204, post-quantum lattice signature.
- SHA-256 via @noble/hashes — same author, same posture.
- WebCrypto for secure random (32-byte challenges).
No network code in this package. HTTP concerns (challenge issuance, revocation list fetching, API auth) live one layer up.
Signing mode. @noble/post-quantum signs ML-DSA-65 with hedged randomization by default, so two signings of the same message produce different bytes. This does NOT affect interop: the signatures verify in Go, Python, Rust, and C/C++, and vice versa. What must match across languages is the canonical signable bytes (SPEC §8.3), and those match byte-for-byte. Only the Go reference generator signs deterministically, so that fixtures regenerate reproducibly; passing { extraEntropy: false } would make this SDK deterministic too, but hedged signing is side-channel hardening and is kept as the default.
API added on main (ships in alpha.16, release unpublished)
The following surface is merged to main and ships in alpha.16. The tag is not yet published; install from main to use it ahead of the release.
- Resource-bound verification. The
resource_pathconstraint (the 8th constraint type, SPEC §5.7.3) binds authority to a named resource via a constraint'sresource_idand optionalpath_prefix. At verify time the application suppliesrequested_resource_id,requested_path, andhas_resourceonVerifierContext(SPEC §5.16);verifyBundle(bundle, { context })evaluates them. Helpers:normalizeResourcePath,resourcePathMatches,validateResourceConstraints. - Operation / session verifier context.
OperationContextandSessionContextInputs, withoperationContextBytes,operationContextHash,sessionContextBytes, andbuildSessionContext;verifierContextHashproduces the canonical hash bound into aVerificationReceipt. - VerificationReceipt wire codecs.
encodeVerificationReceiptanddecodeVerificationReceipt. - Streamed-turn verify options.
StreamedTurnandStreamedVerifyOptions, withverifyStreamedTurnWithOptions(the options-object streamed fast path, SPEC §5.13). - Extension-constraint params. A constraint's
paramscarries parameters for non-canonical constraint types (SPEC §5.7.1), validated byvalidateParamsValue;isCanonicalConstraintTypeguards which types may carry them. - Deeper delegation chains.
MAX_DELEGATION_CHAIN_DEPTHis raised from 3 to 8 (SPEC §5.1). - Input bounds constants.
MAX_PROOF_BUNDLE_BYTES,MAX_SCOPES_PER_CERT,MAX_CONSTRAINTS_PER_CERT,MAX_SCOPE_LENGTH_BYTES,MAX_IDENTIFIER_LENGTH_BYTES,MAX_AGENT_NAME_LENGTH_BYTES,MAX_JSON_NESTING_DEPTH.
License
Apache-2.0
