@handshake-ai/handshake
v0.2.3-build.a2bb4c6
Published
Canonical Handshake protocol SDK — verify cryptographically signed AI agent receipts in browser (WASM) or Node (NAPI-RS).
Maintainers
Readme
@handshake-ai/handshake (TypeScript)
Canonical Handshake protocol SDK for TypeScript / JavaScript. One package name, two runtimes via conditional exports:
| Runtime | Entry | Backed by |
| ------- | ---------------------- | ---------------------------------------------- |
| Browser | dist/wasm/index.js | wasm-pack build of the canonical Rust core |
| Node | dist/index.js | NAPI-RS native addon over the same Rust core |
The cryptographic surface delegates byte-for-byte to the Rust crate at
packages/handshake-rs/, so no binding can drift from the reference
implementation.
Install
pnpm add @handshake-ai/handshakeModern bundlers (Next.js, Vite, esbuild, webpack) automatically pick the WASM entry for browser targets and the NAPI entry for Node.
Browser usage — fail-closed verifier
import { verifyReceipt } from "@handshake-ai/handshake";
const result = await verifyReceipt(envelope, {
registryBaseUrl: "https://registry.handshake.ai",
});
if (result.ok) {
// Render the green "Verified offline ✓" badge.
} else {
// result.reason is one of:
// "envelope-malformed" | "signature-mismatch" | "key-not-found"
// | "key-revoked" | "unsupported-algorithm" | "internal-error"
}verifyReceipt is fail-closed: every code path is wrapped in
try/catch and any thrown exception (network failure, malformed
base64, missing globals) collapses to
{ ok: false, reason: "internal-error" }. The Console (or any other
consumer) never sees a thrown error from this SDK.
Lazy WASM init
The ~500 KB WASM binary is not loaded on package import. The first
call to any function triggers a singleton init() that concurrent
callers share — at most one network fetch per process.
Custom DID resolver
The default resolver fetches GET ${registryBaseUrl}/v1/dids/{did} and
base64url-decodes the primary_ed25519_pubkey_b64u field. To cache,
swap to offline lookup, or surface ML-DSA-65 keys, supply your own
DidResolver:
import { verifyReceipt, type DidResolver } from "@handshake-ai/handshake";
const myResolver: DidResolver = async (did) => ({
primaryEd25519Pubkey: await myCache.get(did),
// optional:
// primaryMldsa65Pubkey: ...,
// revoked: true, // → result.reason = "key-revoked"
});
const result = await verifyReceipt(envelope, { didResolver: myResolver });Notes on the default resolver vs. the live Registry surface:
- Only Ed25519 keys are published today, so
verifyReceiptagainst an ML-DSA-65 envelope returnskey-not-foundunless the caller supplies a custom resolver. - Revocation is signaled by HTTP 404, so the default resolver collapses
revoked-or-missing into
key-not-found. Thekey-revokedreason is reachable only through custom resolvers that setrevoked: trueexplicitly.
Low-level primitives
import {
canonicalize, sha256, ed25519_verify, mldsa65_verify,
} from "@handshake-ai/handshake";
const bytes = await canonicalize({ b: 2, a: 1 }); // → '{"a":1,"b":2}'
const digest = await sha256(bytes);
const ok = await ed25519_verify(publicKey, digest, signature);All primitives are async because they ensure the WASM binary has been initialised. Pure-Node consumers using the NAPI entry get the same function names as synchronous calls.
Node usage
The Node entry exposes the full Phase 4 producer SDK (Handshake,
SoftwareKMS, framework wrappers, etc.):
import { Handshake, SoftwareKMS } from "@handshake-ai/handshake";
const kms = SoftwareKMS.fromSeed({ did: "did:hsk:my-svc", seed });
const hs = new Handshake({ registryUrl, kms });See ts/ for the full surface.
Build
pnpm install
pnpm --filter @handshake-ai/handshake run buildbuild runs three steps:
napi buildcompilessrc/lib.rsinto a platform-specific.nodeaddon plus theindex.cjsloader and rootindex.d.ts.tsc -p tsconfig.jsoncompiles the Node-side façade ints/intodist/.bash wasm/build.shre-runswasm-pack(if installed) againstcrates/handshake-wasm, thentsc -p tsconfig.wasm.jsoncompiles the browser wrapper intodist/wasm/+dist/types/.
Architecture
See docs/decisions/0006-rust-core-authoritative.md for why every
binding is a thin wrapper around one Rust core. Conformance against
the Rust + Go cores is enforced by tests/conformance/ — every
primitive exposed here is byte-tested for equality with the other
implementations.
