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.
299 lines (274 loc) • 16.4 kB
JavaScript
// mdip-agent-kit: give an AI agent its own did:mdip identity (Keychain MDIP / SELF network).
// Library entry point. All secrets live in files under MDIP_HOME with mode 600; nothing here
// ever logs a passphrase or recovery phrase.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import Keymaster from '@mdip/keymaster';
import WalletJson from '@mdip/keymaster/wallet/json';
import GatekeeperClient from '@mdip/gatekeeper/client';
import CipherNode from '@mdip/cipher/node';
/** Default lifetime of a challenge response (keymaster defaults to 1 hour). */
export const DEFAULT_RESPONSE_VALID_DAYS = 7;
/** SELF's official "Public Profile" molecule template (schema: { publicName: string }). */
export const SELF_PUBLIC_PROFILE_SCHEMA = 'did:mdip:z3v8AuagfG89zr9aGxfxub4wRAd15qJs6u9C5jQPgrqC1YRHF67';
export const SELF_EXPLORER_API = 'https://explorer.selfid.com/api/did/';
export const DEFAULTS = {
gatekeeperUrl: 'http://localhost:4224',
registry: 'hyperswarm', // the public MDIP P2P registry SELF reads
didPrefix: 'did:mdip', // SELF network prefix (Keychain repo default is did:test!)
};
/** Resolve configuration from explicit options, then env, then defaults. */
export function loadConfig(opts = {}) {
const home = path.resolve(opts.home || process.env.MDIP_HOME || path.join(os.homedir(), '.mdip-agent'));
return {
home,
name: opts.name || process.env.MDIP_AGENT_NAME || 'agent',
gatekeeperUrl: opts.gatekeeperUrl || process.env.MDIP_GATEKEEPER_URL || DEFAULTS.gatekeeperUrl,
registry: opts.registry || process.env.MDIP_REGISTRY || DEFAULTS.registry,
didPrefix: opts.didPrefix || process.env.MDIP_DID_PREFIX || DEFAULTS.didPrefix,
paths: {
wallet: path.join(home, 'wallet.json'),
passphrase: path.join(home, 'secrets', 'passphrase'),
recoveryPhrase: path.join(home, 'secrets', 'recovery-phrase.txt'),
backups: path.join(home, 'backups'),
state: path.join(home, 'state.json'),
},
};
}
function ensurePrivateDir(dir) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700);
}
function writeSecret(file, contents) {
ensurePrivateDir(path.dirname(file));
fs.writeFileSync(file, contents, { mode: 0o600 });
fs.chmodSync(file, 0o600);
}
function readState(cfg) {
try { return JSON.parse(fs.readFileSync(cfg.paths.state, 'utf8')); } catch { return {}; }
}
function writeState(cfg, patch) {
const next = { ...readState(cfg), ...patch, updatedAt: new Date().toISOString() };
fs.writeFileSync(cfg.paths.state, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
return next;
}
/** Poll until a DID resolves (fresh operations are processed asynchronously by the gatekeeper). */
export async function waitForDid(keymaster, did, { timeoutMs = 60000, intervalMs = 1000 } = {}) {
const end = Date.now() + timeoutMs;
for (;;) {
try { const doc = await keymaster.resolveDID(did); if (doc?.didDocument?.id) return doc; } catch { /* not yet */ }
if (Date.now() > end) throw new Error(`DID ${did} not resolvable after ${timeoutMs} ms`);
await new Promise(r => setTimeout(r, intervalMs));
}
}
/** Build a Keymaster bound to a wallet directory and passphrase. Low-level; prefer openAgent(). */
export async function createKeymaster({ gatekeeperUrl, walletDir, passphrase, registry = DEFAULTS.registry, didPrefix = DEFAULTS.didPrefix, wait = true }) {
const gatekeeper = new GatekeeperClient();
await gatekeeper.connect({ url: gatekeeperUrl, waitUntilReady: wait, intervalSeconds: 3, chatty: false });
ensurePrivateDir(walletDir);
const wallet = new WalletJson('wallet.json', walletDir);
return new Keymaster({ gatekeeper, wallet, cipher: new CipherNode(), defaultRegistry: registry, didPrefix, passphrase });
}
/**
* Open (or create, with create:true) this agent's identity.
* Returns an Agent with helpers for signing, challenges, credentials and backups.
*/
export async function openAgent(opts = {}) {
const cfg = loadConfig(opts);
ensurePrivateDir(cfg.home);
const exists = fs.existsSync(cfg.paths.wallet);
if (!exists && !opts.create) {
throw new Error(`No wallet at ${cfg.paths.wallet}. Run "mdip-agent init" first (or pass create:true).`);
}
if (!fs.existsSync(cfg.paths.passphrase)) {
if (exists) throw new Error(`Wallet exists but passphrase file ${cfg.paths.passphrase} is missing.`);
writeSecret(cfg.paths.passphrase, crypto.randomBytes(32).toString('base64url'));
}
const passphrase = fs.readFileSync(cfg.paths.passphrase, 'utf8').trim();
const keymaster = await createKeymaster({ gatekeeperUrl: cfg.gatekeeperUrl, walletDir: cfg.home, passphrase, registry: cfg.registry, didPrefix: cfg.didPrefix });
if (!exists) {
await keymaster.newWallet(opts.mnemonic || undefined, false);
fs.chmodSync(cfg.paths.wallet, 0o600);
}
let ids = await keymaster.listIds();
if (!ids.includes(cfg.name)) {
if (!opts.create) throw new Error(`Identity "${cfg.name}" not in wallet (have: ${ids.join(', ') || 'none'})`);
await keymaster.createId(cfg.name, { registry: cfg.registry });
ids = await keymaster.listIds();
}
// A fresh DID can take a moment before the gatekeeper resolves it; wait so follow-up
// operations (issuing, publishing) don't fail with "Invalid DID".
await waitForDid(keymaster, (await keymaster.fetchIdInfo(cfg.name)).did);
await keymaster.setCurrentId(cfg.name);
const did = (await keymaster.fetchIdInfo(cfg.name)).did;
if (!fs.existsSync(cfg.paths.recoveryPhrase)) {
writeSecret(cfg.paths.recoveryPhrase, (await keymaster.decryptMnemonic()).trim() + '\n');
}
writeState(cfg, { name: cfg.name, did, gatekeeperUrl: cfg.gatekeeperUrl, registry: cfg.registry });
return new Agent(cfg, keymaster, did);
}
export class Agent {
constructor(cfg, keymaster, did) {
this.config = cfg;
/** Underlying @mdip/keymaster instance for anything not wrapped here. */
this.keymaster = keymaster;
this.did = did;
}
get state() { return readState(this.config); }
resolve(did = this.did) { return this.keymaster.resolveDID(did); }
// ---- signing -------------------------------------------------------------------------
/** Sign any JSON object with this agent's DID key (adds a `signature` block). */
sign(obj) { return this.keymaster.addSignature(obj); }
/** Verify a signed JSON object; resolves the signer DID on the network. */
verify(signed) { return this.keymaster.verifySignature(signed); }
// ---- challenges ("every interaction is an invite") ------------------------------------
/**
* Create a challenge DID another agent/human must answer. Optionally require credentials:
* createChallenge({ credentials: [{ schema: SCHEMA_DID, issuers: [ISSUER_DID] }], callback })
*/
createChallenge(challenge = {}, options = {}) { return this.keymaster.createChallenge(challenge, options); }
/** Answer someone else's challenge DID; returns a response DID to send back. */
/**
* Answer someone else's challenge DID; returns a response DID to send back.
* The response stays resolvable for `validDays` (default 7; keymaster's own default is 1 hour),
* capped at the challenge's own validUntil if it has one. Pass `validUntil` to set it exactly.
*/
async respondToChallenge(challengeDid, options = {}) {
const { validDays = DEFAULT_RESPONSE_VALID_DAYS, ...rest } = options;
if (!rest.validUntil) rest.validUntil = await this.responseValidUntil(challengeDid, validDays);
return this.keymaster.createResponse(challengeDid, rest);
}
/** Compute a response expiry: now + validDays, capped at the challenge's validUntil. */
async responseValidUntil(challengeDid, validDays = DEFAULT_RESPONSE_VALID_DAYS) {
const days = Number(validDays);
if (!Number.isFinite(days) || days <= 0) throw new Error('validDays must be a positive number');
let until = Date.now() + days * 24 * 60 * 60 * 1000;
const doc = await this.keymaster.resolveDID(challengeDid);
const cap = doc?.mdip?.validUntil ? Date.parse(doc.mdip.validUntil) : NaN;
if (Number.isFinite(cap)) {
if (cap <= Date.now()) throw new Error(`challenge expired at ${doc.mdip.validUntil}`);
until = Math.min(until, cap);
}
return new Date(until).toISOString();
}
/** Verify a response DID to a challenge you created. Result has .match, .responder, .vps... */
verifyResponse(responseDid, options = {}) { return this.keymaster.verifyResponse(responseDid, options); }
// ---- credentials -----------------------------------------------------------------------
/** Accept a credential issued to this agent (e.g. a purchase authorization from a SELF app). */
acceptCredential(credentialDid) { return this.keymaster.acceptCredential(credentialDid); }
listCredentials() { return this.keymaster.listCredentials(); }
getCredential(credentialDid) { return this.keymaster.getCredential(credentialDid); }
/** Publish a held credential to the DID manifest; reveal:true exposes its content publicly. */
publishCredential(credentialDid, { reveal = false } = {}) { return this.keymaster.publishCredential(credentialDid, { reveal }); }
/**
* Find held credentials (decrypted) by schema and/or issuer - e.g. check that a human's
* DID authorized this agent to purchase: findCredentials({ schema: PURCHASE_SCHEMA, issuer: HUMAN_DID }).
* Also filters out credentials whose validUntil has passed.
*/
async findCredentials({ schema, issuer } = {}) {
const found = [];
for (const did of await this.listCredentials()) {
const vc = await this.getCredential(did).catch(() => null);
if (!vc) continue;
if (schema && !vc.type?.includes(schema)) continue;
if (issuer && vc.issuer !== issuer) continue;
if (vc.validUntil && Date.parse(vc.validUntil) < Date.now()) continue;
if (!(await this.keymaster.verifySignature(vc).catch(() => false))) continue;
found.push({ did, ...vc });
}
return found;
}
/** Issue a credential from this agent to `subjectDid` using a schema DID. Returns credential DID. */
async issueCredential(schemaDid, subjectDid, credential, options = {}) {
const bound = await this.keymaster.bindCredential(schemaDid, subjectDid, { credential, ...options });
return this.keymaster.issueCredential(bound, { registry: this.config.registry, ...options });
}
/**
* Self-attest SELF's Public Profile molecule with ONLY { publicName }, accept it,
* and publish it to the manifest with content revealed. Idempotent per name.
*/
async selfAttestPublicProfile(publicName) {
// Reuse an existing self-issued Public Profile with the same name if we already hold one.
let credentialDid;
for (const did of await this.listCredentials()) {
const vc = await this.getCredential(did).catch(() => null);
if (vc?.type?.includes(SELF_PUBLIC_PROFILE_SCHEMA) && vc.issuer === this.did && vc.credential?.publicName === publicName) { credentialDid = did; break; }
}
if (!credentialDid) {
credentialDid = await this.issueCredential(SELF_PUBLIC_PROFILE_SCHEMA, this.did, { publicName });
await waitForDid(this.keymaster, credentialDid);
await this.acceptCredential(credentialDid);
}
await this.publishCredential(credentialDid, { reveal: true });
const publicProfile = { publicName, credentialDid, schema: SELF_PUBLIC_PROFILE_SCHEMA };
writeState(this.config, { publicProfile });
return publicProfile;
}
// ---- backups ---------------------------------------------------------------------------
/**
* Two backups: (1) network - encrypted ID vault (first time) + encrypted wallet asset on the
* registry, linked from the mnemonic-derived seed bank DID; (2) local encrypted wallet file.
* Re-run after any wallet change (new credentials etc.).
*/
async backup() {
const doc = await this.resolve();
if (!doc.didDocumentData?.vault) await this.keymaster.backupId(this.config.name);
const walletBackupDid = await this.keymaster.backupWallet(this.config.registry);
ensurePrivateDir(this.config.paths.backups);
const file = path.join(this.config.paths.backups, `wallet-${new Date().toLocaleDateString('sv')}.json`);
writeSecret(file, JSON.stringify(await this.keymaster.exportEncryptedWallet(), null, 2));
writeState(this.config, { walletBackupDid, lastLocalBackup: file });
return { walletBackupDid, file };
}
/**
* Non-destructive restore test: restores (a) recovery phrase + network backup and
* (b) newest local backup file into throwaway temp wallets; checks the same DID, held
* credentials and a working signing key. Writes nothing to the network.
*/
async verifyBackups() {
const cfg = this.config;
const expectCreds = await this.listCredentials();
const results = [];
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mdip-verify-'));
const check = async (label, km) => {
let ok = false, detail = {};
try {
await km.setCurrentId(cfg.name);
const did = (await km.fetchIdInfo(cfg.name)).did;
const creds = await km.listCredentials();
const signed = await km.addSignature({ verify: label, at: Date.now() });
const sigOk = await km.verifySignature(signed);
const missing = expectCreds.filter(c => !creds.includes(c));
ok = did === this.did && missing.length === 0 && sigOk;
detail = { sameDid: did === this.did, credentials: creds.length, missingCredentials: missing.length, signatureVerifies: sigOk };
} catch (e) { detail = { error: String(e?.message || e) }; }
results.push({ test: label, pass: ok, ...detail });
};
try {
const km1 = await createKeymaster({ gatekeeperUrl: cfg.gatekeeperUrl, walletDir: path.join(tmpRoot, 'net'), passphrase: crypto.randomBytes(24).toString('base64url'), registry: cfg.registry, didPrefix: cfg.didPrefix });
await km1.newWallet(fs.readFileSync(cfg.paths.recoveryPhrase, 'utf8').trim(), true);
await km1.recoverWallet();
await check('recovery-phrase+network-backup', km1);
const files = fs.existsSync(cfg.paths.backups) ? fs.readdirSync(cfg.paths.backups).filter(f => f.endsWith('.json')).sort() : [];
if (files.length) {
const passphrase = fs.readFileSync(cfg.paths.passphrase, 'utf8').trim();
const km2 = await createKeymaster({ gatekeeperUrl: cfg.gatekeeperUrl, walletDir: path.join(tmpRoot, 'file'), passphrase, registry: cfg.registry, didPrefix: cfg.didPrefix });
const saved = await km2.saveWallet(JSON.parse(fs.readFileSync(path.join(cfg.paths.backups, files.at(-1)), 'utf8')), true);
if (saved) await check(`local-file:${files.at(-1)}`, km2);
else results.push({ test: 'local-file', pass: false, error: 'saveWallet failed' });
} else results.push({ test: 'local-file', pass: false, error: 'no local backup found' });
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
return { pass: results.every(r => r.pass), results };
}
/** Check whether this DID (or another) is visible on SELF's public explorer. */
async explorerStatus(did = this.did) {
const res = await fetch(SELF_EXPLORER_API + did);
const body = await res.json().catch(() => null);
if (!body || typeof body === 'string') return { visible: false };
return { visible: true, version: body.didDocumentMetadata?.version, confirmed: body.didDocumentMetadata?.confirmed, manifest: Object.keys(body.didDocumentData?.manifest || {}) };
}
}