# 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.4: vector checks parse JSON with jq and compare expiry as epoch ms; 0.1.3 added the offline challenge↔response binding vector + strict-expiry check). 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.3.tgz | tar xz
cd package/vectors
for d in kav-1 mdip-challenge-1 mdip-challenge-2; do (cd $d && sha256sum -c SHA256SUMS); done   # 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)

# C. mdip-challenge-2 (new in 0.1.3, thanks @homies): offline challenge <-> response BINDING.
#    The verifier is a PUBLIC TEST identity, so anyone can decrypt the response it received.
d=mdip-challenge-2
openssl pkey -in $d/verifier-agent.PUBLIC-TEST-KEY.priv.pem -pubout | cmp - $d/verifier-agent.pub.pem   # same key
bash decrypt-openssl.sh $d/verifier-agent.PUBLIC-TEST-KEY.priv.pem $d/responder-agent.pub.pem $d/response.cipher_receiver.bin
#   {"response":{"challenge":"did:mdip:...","credentials":[],...,"match":true,"responseNonce":"..."}}
#   the "challenge" value must equal dids.json "challenge"; verify-openssl.sh checks that, plus:
#   - response.cipher_receiver.bin is exactly cipher_receiver inside the SIGNED response op
#   - every DID == did:mdip:z + base58btc(01 8004 1220 || sha256(<name>.signed-op.bin))  (DID is the op hash)
#   - challenge controller == verifier DID; response controller == encrypted.sender == responder DID
#   - a flipped ciphertext byte fails the Poly1305 tag

# D. strict expiry: response.validUntil <= challenge.validUntil. Equal is allowed, 1 ms later is not.
cat expiry-cases.txt          # the table; verify-openssl.sh and verify-with-kit.js both run it

# 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 (see C for an offline-checkable version). These DIDs were short-lived (1 h), so they may no longer resolve. The fixture is for
  offline checks.
- **C (`mdip-challenge-2`, 0.1.3)** closes the gap @homies found in B: there the binding sat in
  ciphertext only the verifier could open. Here the verifier is a **PUBLIC TEST identity**: its wallet
  mnemonic is derived from a published label (`test-verifier.json`) and its agent private key is
  shipped as `verifier-agent.PUBLIC-TEST-KEY.priv.pem`. `decrypt-openssl.sh` decrypts the response
  with stock OpenSSL only. MDIP's wire format (`@mdip/cipher` 2.2.0): key = first 32 bytes of the
  compressed secp256k1 ECDH point (parity byte + 31 bytes of X), blob = `nonce(24) || ct || tag(16)`,
  XChaCha20-Poly1305, no AAD. HChaCha20 is computed from one raw `openssl enc -chacha20` block and the
  tag with `openssl mac POLY1305`. So an offline stranger can check, from signed bytes only: the verifier
  signed challenge X, the responder signed a response whose plaintext says `"challenge": X`, and each
  DID string is the hash of its op. Real verifiers keep their key private, so in production only the
  verifier can make this check. The vector proves the format and the kit's binding logic, not a stranger's
  view of a live exchange.
- **D (strict expiry)**: the intended rule is that a response **expires no later than** its challenge
  (`response.validUntil <= challenge.validUntil`). Equal is valid because `respond` caps at exactly the
  challenge expiry, which is why B and C show equal timestamps to the millisecond. One ms later is
  rejected, and so is a missing response expiry when the challenge has one. Since 0.1.3,
  `agent.verifyResponse()` / `mdip-agent verify-response` enforce this, plus "neither already expired",
  and return `expiry: { ok, reason }` with `match: false` on failure (exported as `checkResponseExpiry`).
- **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`).
