mdip-agent-kit
Version:
Give any AI agent its own MDIP (did:mdip) identity on the SELF / Keychain hyperswarm network: keymaster wallet, DID, SELF Public Profile molecule, backups, challenges and delegated-authority credentials.
272 lines (219 loc) • 15.5 kB
Markdown
# mdip-agent-kit
Give your AI agent its own **decentralized identity** (`did:mdip`) on the public
[Keychain MDIP](https://keychain.org) hyperswarm network, the network used by the
[SELF®](https://selfid.com) identity app. One command gets the agent:
- its own **Keymaster wallet** (encrypted, local) and **agent DID**
- SELF's official **Public Profile molecule**, self-attested and **published + revealed** on its DID manifest
- **two backups**: a network backup recoverable from a 12-word recovery phrase, plus a local encrypted file
- an automatic **restore test** of both backups, run without touching the live wallet
- a small library/CLI to **sign/verify**, **issue and answer challenges**, and **accept credentials**
such as a purchase authorization issued from a human's SELF app
> Install: `npm install mdip-agent-kit` · run: `npx mdip-agent-kit <command>`
>
> Status: early (0.1.2). Not affiliated with SELF or Keychain. License MIT (see `LICENSE`; can be changed).
## Why
Agents act for people. An agent DID gives every action a verifiable author. A **delegated-authority
credential** issued by a human's DID ("this agent may buy things for me, up to $X, until Y") lets
anyone check that authority cryptographically, with no shared passwords and no API keys handed over.
Challenge/response lets agents check each other before they interact. *Every interaction is an
invite*: each time you challenge another agent, it is pushed to get a DID too.
## Requirements
- Node.js ≥ 22
- A **Gatekeeper** URL (the MDIP node your keymaster talks to). See [Choosing a gatekeeper](#choosing-a-gatekeeper).
## Quickstart
```bash
# 1. a gatekeeper: run your own MDIP node (Docker). The script ships in the package:
npm install mdip-agent-kit
bash node_modules/mdip-agent-kit/scripts/run-local-node.sh # -> http://127.0.0.1:4224, syncs with the public network
export MDIP_GATEKEEPER_URL=http://127.0.0.1:4224
# 2. identity + public profile + backups + restore test
export MDIP_HOME=~/.mdip-agent/my-agent # one dir per agent, never shared
export MDIP_AGENT_NAME=my-agent
npx mdip-agent-kit init --profile-name "My Agent"
# 3. confirm it reached the public network (usually < 2 min)
npx mdip-agent-kit whoami # includes explorer visibility
curl -s https://explorer.selfid.com/api/did/<your did>
```
Once installed locally (`npm install mdip-agent-kit`), the binary is also available as `npx mdip-agent ...`.
`init` is idempotent: re-running it reuses the wallet, DID, and profile.
## CLI
Run as `npx mdip-agent-kit <command>` (or `npx mdip-agent <command>` once installed locally).
```
mdip-agent init [--profile-name "Name"] [--no-backup]
mdip-agent whoami | profile "<name>" | backup | verify-backups | explorer [did]
mdip-agent resolve <did>
mdip-agent sign <file|-> | verify <file|->
mdip-agent challenge [--schema DID]... [--issuer DID]... [--callback URL]
mdip-agent respond <challengeDid> [--valid-days N] | verify-response <responseDid>
mdip-agent accept <credentialDid> | credentials | credential <did>
```
Responses stay valid 7 days by default (`--valid-days N` to change; always capped at the
challenge's own expiry). Keymaster's built-in default is only 1 hour.
Global flags: `--home DIR`, `--gatekeeper URL`, `--name ID`.
## Library
```js
import { openAgent, SELF_PUBLIC_PROFILE_SCHEMA } from 'mdip-agent-kit';
const agent = await openAgent(); // or openAgent({ home, gatekeeperUrl, name, create: true })
agent.did; // did:mdip:...
const signed = await agent.sign({ order: 42 }); // JSON + signature block
await agent.verify(signed); // true
// challenge another party (optionally require credentials)
const ch = await agent.createChallenge({ credentials: [{ schema: SELF_PUBLIC_PROFILE_SCHEMA }] });
const resp = await other.respondToChallenge(ch); // done by the other agent
(await agent.verifyResponse(resp)).match; // true
// delegated authority from a human
await agent.acceptCredential(credentialDidFromSelfApp);
const grants = await agent.findCredentials({ schema: PURCHASE_SCHEMA, issuer: HUMAN_DID });
await agent.backup(); // network + local file
await agent.verifyBackups(); // { pass, results }
agent.keymaster; // full @mdip/keymaster API for everything else
```
See `examples/agent-handshake.js` and `examples/purchase-authorization.js`.
## Configuration
| env | default | meaning |
| --- | --- | --- |
| `MDIP_HOME` | `~/.mdip-agent` | wallet, secrets, backups, state. **One per agent.** |
| `MDIP_GATEKEEPER_URL` | `http://localhost:4224` | Gatekeeper REST endpoint |
| `MDIP_AGENT_NAME` | `agent` | ID name inside the wallet (not public) |
| `MDIP_REGISTRY` | `hyperswarm` | registry for new DIDs (what SELF reads) |
| `MDIP_DID_PREFIX` | `did:mdip` | DID prefix used on the SELF network. The Keychain repo's sample config uses `did:test`; don't use that if you want SELF to see you |
Layout of `MDIP_HOME` (dir mode 700, files 600):
```
wallet.json encrypted keymaster wallet
secrets/passphrase wallet encryption passphrase (random, generated)
secrets/recovery-phrase.txt 12-word BIP39 recovery phrase
backups/wallet-YYYY-MM-DD.json encrypted wallet export (needs the passphrase)
state.json non-secret: DID, profile credential DID, backup DID
```
## Choosing a gatekeeper
The keymaster (your wallet) lives in your process. It only needs a Gatekeeper to read and write DID
operations. Options:
1. **Your own node (recommended).** `scripts/run-local-node.sh` runs the official
[KeychainMDIP/kc](https://github.com/KeychainMDIP/kc) Docker Compose stack (gatekeeper +
hyperswarm mediator) with `did:mdip` / `hyperswarm`, bound to localhost. It peers with the public
network (observed peers include SELF's nodes). The first sync pulls ~150k operations
(as of Oct 2026). Several agents on one machine can share one gatekeeper; each still gets its
own `MDIP_HOME` wallet.
2. **Another MDIP gatekeeper that you operate or that explicitly allows you to write.** There is no
public write gatekeeper offered by this kit; don't point agents at someone else's production
gatekeeper without permission.
To check publication, use SELF's public explorer API (`https://explorer.selfid.com/api/did/<did>`),
not a gatekeeper.
Your private keys never leave `MDIP_HOME`, whichever gatekeeper you use. A gatekeeper only sees
signed public operations.
## Verifying on the public network
```bash
curl -s https://explorer.selfid.com/api/did/<did> # agent DID: manifest shows the profile
npx mdip-agent-kit explorer <did> # same, summarized
```
A freshly created DID is normally visible within 1–2 minutes. If the node had no peers at that
moment, the operation goes out later via the mediator's periodic reconciliation, once the node is
fully synced. That can take a while on a brand-new node.
## Known-answer vectors (verify with stock OpenSSL, no install)
Fixed bytes so another agent can check this kit's signature crypto **without running any of its
code**. You need `curl`, `tar`, `sha256sum` and OpenSSL ≥ 1.1.1 with secp256k1 (stock builds have it).
Every signed message is non-empty, so `openssl pkeyutl` also works.
**What MDIP actually signs** (from `@mdip/cipher` / `@mdip/keymaster` 2.2.0, used by `sign`,
`verify` and every DID operation): `hash = sha256(JCS(obj without "signature"))` (RFC 8785 JSON
canonicalization, UTF-8). `signature.value` = secp256k1 ECDSA over that 32-byte hash (RFC 6979,
low-S), stored as 64-byte compact `r||s` hex. MDIP does **not** use Ed25519. For stock tools,
each vector ships the exact canonical bytes (`*.message.bin`), the signature re-encoded as DER
(`*.sig.der`) and the public key as SPKI PEM.
```bash
# fetch the published files: a plain tarball download, nothing is installed or executed
curl -sL https://registry.npmjs.org/mdip-agent-kit/-/mdip-agent-kit-0.1.2.tgz | tar xz
cd package/vectors
(cd kav-1 && sha256sum -c SHA256SUMS) && (cd mdip-challenge-1 && sha256sum -c SHA256SUMS) # all OK
# A. kav-1: synthetic vector, PUBLIC TEST KEY (private part published in test-key.json on purpose)
openssl dgst -sha256 -r kav-1/message.bin
# 4d4523d99bebdd70689f67a80e865349848f520d19ab9537403e56a280dcf64b *kav-1/message.bin (== kav-1/message.sha256)
openssl dgst -sha256 -verify kav-1/pub.pem -signature kav-1/signature.der kav-1/message.bin
# Verified OK (exit 0)
openssl dgst -sha256 -verify kav-1/pub.pem -signature kav-1/signature.der kav-1/message-tampered.bin
# Verification failure (exit 1, one byte changed)
openssl dgst -sha256 -binary kav-1/message.bin > /tmp/h.bin # same check on the raw 32-byte hash
openssl pkeyutl -verify -pubin -inkey kav-1/pub.pem -in /tmp/h.bin -sigfile kav-1/signature.der
# Signature Verified Successfully (exit 0)
# B. mdip-challenge-1: a REAL challenge -> response captured from this kit (throwaway identities)
for p in verifier-agent:verifier-agent challenge:verifier-agent responder-agent:responder-agent response:responder-agent; do
op=${p%%:*} key=${p##*:}
openssl dgst -sha256 -verify mdip-challenge-1/$key.pub.pem -signature mdip-challenge-1/$op.sig.der mdip-challenge-1/$op.message.bin
done
# Verified OK x4 (exit 0 each)
# or all of the above in one go (bash + openssl only):
bash verify-openssl.sh # last line: ALL VECTORS OK, exit 0
```
| File | Value |
|---|---|
| `kav-1/message.bin` (240 bytes, JCS of `payload.json`) | sha256 `4d4523d99bebdd70689f67a80e865349848f520d19ab9537403e56a280dcf64b` |
| `kav-1/signature.hex` (compact r‖s) | `2b61bc63136caf5dbbdfee80d6717d57df09555be0f6c1b0158adc70feb0363c` `2067a7017b2b52fd92fd0e980650118bf5d4e07bc88ee8527f04f6404a37b4d1` |
| `kav-1/signature.der` | sha256 `fdc8e19ec10ced978a9dbd5ae599c35fa715d561dd1635bb5cf0360e3acd4508` |
| `kav-1/pub.pem` | sha256 `43d15804c3379b189d1d1fbfb391d3c42abf739587297bdb9987eab382bcd131` |
| `kav-1/test-key.json` label | `sha256("mdip-agent-kit kav-1 PUBLIC TEST KEY - never use for anything real")` = private key |
**What each vector proves, and what it doesn't:**
- **A (`kav-1`)** proves your stock OpenSSL agrees with the kit's sign/verify primitive on fixed
bytes. The payload is example application data, **not** MDIP's challenge wire format. Its signer
(`did:example:...`) doesn't resolve, so `mdip-agent verify kav-1/signed.json` will fail by design.
Regenerate it with `node vectors/generate.js` (deterministic, no diff expected).
- **B (`mdip-challenge-1`)** contains the real signed create operations (`*.op.json`) for two
throwaway agents, a challenge DID and the response DID, plus the verifier's decrypted
`verifyResponse` result (`verify-response.json`). OpenSSL proves that the challenge was signed
by the verifier's key and the response by the responder's key. Read the JSON to check the bindings:
the challenge `controller` is the verifier DID; the response `controller` and `data.encrypted.sender`
are the responder DID; the response `mdip.validUntil` ≤ the challenge's (expiry, capped by this kit).
The response payload (`challenge` DID, `responseNonce`, `match`) is **encrypted to the verifier**,
so only the verifier can check it; `verify-response.json` is what the verifier saw. These DIDs were short-lived (1 h), so they may no longer resolve. The fixture is for
offline checks.
- **Not covered offline:** DID resolution, key rotation and revocation. `verifyResponse` re-resolves the
challenge and each presented credential **at verify time** and ignores deactivated or revoked ones.
That needs a gatekeeper. Check it with the kit (`npm test`) or your own MDIP node.
- With the kit installed, `node vectors/verify-with-kit.js` re-checks everything with `@mdip/cipher`
(recomputes JCS from the JSON instead of trusting the `.bin` files).
## Backup & restore
`init` and `backup` create:
1. **Network backup.** `backupId` (encrypted ID vault linked from the agent DID) and `backupWallet`
(encrypted wallet asset). The 12-word recovery phrase derives a deterministic "seed bank" DID that
points to the latest backup. Restore with **only the phrase**:
```js
const km = await createKeymaster({ gatekeeperUrl, walletDir, passphrase: 'new-passphrase' });
await km.newWallet(phrase, true); await km.recoverWallet();
```
2. **Local file.** `backups/wallet-<date>.json`, encrypted with `secrets/passphrase`. Restore with
`keymaster.saveWallet(JSON.parse(file), true)` using the same passphrase.
`verify-backups` restores both into throwaway temp wallets and checks that you get the same DID,
the same held credentials, and a working signing key. It writes nothing to the network.
**Re-run `backup` after any wallet change** (new credentials, new IDs). Copy the recovery phrase
somewhere offline: anyone holding it controls the agent's identity.
## Security notes
- Secrets are generated locally, stored mode 600, `.gitignore`d, and never printed by the CLI.
Treat `MDIP_HOME` like an SSH key directory.
- **Revealed** credentials (like the Public Profile) are public forever on an append-only network.
Only reveal what you'd put on a billboard. Use *publish* without reveal, or nothing, for anything else.
- Challenges and responses are short-lived public DIDs. Their credential presentations are encrypted to the verifier.
- Accepting a credential doesn't make it valid. Always check the issuer, schema, and expiry before
acting (`findCredentials` does this and verifies signatures). Revocation is possible: re-check before acting.
- Don't expose your gatekeeper's write API or any keymaster REST port to the Internet.
- npm ≥ 11 may skip `secp256k1`'s native build (`npm install-scripts approve secp256k1` to allow it).
The pure-JS fallback works.
## Troubleshooting
- **Containers can't reach each other** (gatekeeper logs `Redis connection error ... connect ETIMEDOUT`,
services never become ready): some hosts ship an **`iptables-legacy` FORWARD policy of DROP**.
With `br_netfilter` that silently drops traffic between containers on Docker bridges, even though
`iptables` (nft) looks fine. Check with `sudo iptables-legacy -S FORWARD`. Fix:
```bash
for r in "-i br+" "-o br+" "-i docker0" "-o docker0"; do
sudo iptables-legacy -C FORWARD $r -j ACCEPT 2>/dev/null || sudo iptables-legacy -I FORWARD 1 $r -j ACCEPT; done
```
The rules are not persistent across reboots. Re-apply them at boot.
- **No systemd** (containers, sandboxes): start `dockerd` yourself. Compose `restart: unless-stopped`
revives the node whenever dockerd starts.
- **`Invalid DID` right after creating something**: the gatekeeper processes operations
asynchronously. The kit waits for new DIDs (`waitForDid`). Do the same in your own code.
- **DID not on explorer.selfid.com**: check `docker compose logs hypr-mediator` for
`peer capabilities received`. You need at least one peer. The node also needs outbound UDP (Hyperswarm DHT).
## Credits
Built on [@mdip/keymaster](https://www.npmjs.com/package/@mdip/keymaster),
[@mdip/gatekeeper](https://www.npmjs.com/package/@mdip/gatekeeper) and
[@mdip/cipher](https://www.npmjs.com/package/@mdip/cipher) (KeychainMDIP). SELF Public Profile
template DID `did:mdip:z3v8AuagfG89zr9aGxfxub4wRAd15qJs6u9C5jQPgrqC1YRHF67` (from
`explorer.selfid.com/api/templates`).