UNPKG

mdip-agent-kit

Version:

Give any AI agent its own MDIP (did:mdip) identity on the SELF (https://selfid.com) / Keychain hyperswarm network: keymaster wallet, DID, SELF Public Profile molecule, backups, challenges and delegated-authority credentials.

335 lines (309 loc) • 18.9 kB
// 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. */ /** * Strict-expiry rule for challenge responses (intended semantics, fixtures in vectors/expiry-cases.txt): * a response must expire NO LATER than its challenge (response.validUntil <= challenge.validUntil; equal is * allowed because respond caps at exactly the challenge's expiry). If the challenge has a validUntil the * response must have one too. With `now` (ms), both must also still be in the future. * @returns {{ok: boolean, reason?: string}} */ export function checkResponseExpiry(challengeValidUntil, responseValidUntil, now) { // a timestamp without Z or an offset would be read in the verifier's local zone: reject it (fail closed) const zoned = (t) => /(Z|[+-]\d{2}:?\d{2})$/.test(String(t)); if (challengeValidUntil && !zoned(challengeValidUntil)) return { ok: false, reason: 'challenge validUntil has no timezone' }; if (responseValidUntil && !zoned(responseValidUntil)) return { ok: false, reason: 'response validUntil has no timezone' }; const c = challengeValidUntil ? Date.parse(challengeValidUntil) : NaN; const r = responseValidUntil ? Date.parse(responseValidUntil) : NaN; if (challengeValidUntil && !Number.isFinite(c)) return { ok: false, reason: 'challenge validUntil unparseable' }; if (responseValidUntil && !Number.isFinite(r)) return { ok: false, reason: 'response validUntil unparseable' }; if (Number.isFinite(c) && !Number.isFinite(r)) return { ok: false, reason: 'response has no validUntil but challenge does' }; if (Number.isFinite(c) && r > c) return { ok: false, reason: `response validUntil ${responseValidUntil} is later than challenge validUntil ${challengeValidUntil}` }; if (now !== undefined) { if (Number.isFinite(c) && c <= now) return { ok: false, reason: `challenge expired at ${challengeValidUntil}` }; if (Number.isFinite(r) && r <= now) return { ok: false, reason: `response expired at ${responseValidUntil}` }; } return { ok: true }; } 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... * Since 0.1.3 also enforces strict expiry (see checkResponseExpiry): a response that outlives its * challenge, or either one already expired, gives match:false with .expiry.reason set. */ async verifyResponse(responseDid, options = {}) { const result = await this.keymaster.verifyResponse(responseDid, options); const [respDoc, chDoc] = await Promise.all([this.keymaster.resolveDID(responseDid), this.keymaster.resolveDID(result.challenge)]); const expiry = checkResponseExpiry(chDoc?.mdip?.validUntil, respDoc?.mdip?.validUntil, Date.now()); result.expiry = expiry; if (!expiry.ok) result.match = false; return result; } // ---- 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 || {}) }; } }