|
| 1 | +import { |
| 2 | + createPublicKey, |
| 3 | + generateKeyPairSync, |
| 4 | + sign as edSign, |
| 5 | + verify as edVerify, |
| 6 | + type KeyObject, |
| 7 | +} from "node:crypto"; |
| 8 | +import type { OpenAgentPersona } from "@agent-space/domain"; |
| 9 | + |
| 10 | +/** |
| 11 | + * Node-layer signer for OpenAgent persona-cards. The pure `employeeToPersona()` |
| 12 | + * mapper (packages/domain) produces an unsigned persona; this composes on top of |
| 13 | + * it to attach a self-verifying ed25519 provenance block and derive the agent's |
| 14 | + * did:key address. It lives here — not in packages/domain — because it depends on |
| 15 | + * node:crypto and Buffer, which the runtime-agnostic domain build cannot type. |
| 16 | + * |
| 17 | + * The canonicalisation, ed25519 signature and did:key derivation mirror |
| 18 | + * `@5dive/openagent` (lib/provenance.js) exactly, so a signed export validates and |
| 19 | + * verifies with `npx @5dive/openagent validate` / provenance verify without any |
| 20 | + * dependency on the (CommonJS) CLI package. |
| 21 | + */ |
| 22 | + |
| 23 | +export interface SignPersonaOptions { |
| 24 | + /** Fixed timestamp (ISO 8601) for the provenance block; defaults to now. */ |
| 25 | + now?: string; |
| 26 | + /** Deterministic keypair injection for tests / reproducible exports. */ |
| 27 | + keyPair?: { publicKey: KeyObject; privateKey: KeyObject }; |
| 28 | +} |
| 29 | + |
| 30 | +export interface SignPersonaResult { |
| 31 | + persona: OpenAgentPersona; |
| 32 | + /** The agent's canonical public address, e.g. "did:key:z6Mk…". */ |
| 33 | + didKey: string; |
| 34 | +} |
| 35 | + |
| 36 | +// ---- canonicalisation (mirrors @5dive/openagent lib/provenance.js) ---------- |
| 37 | + |
| 38 | +// Deterministic JSON: object keys sorted recursively, primitives via JSON.stringify. |
| 39 | +function stableStringify(value: unknown): string { |
| 40 | + if (Array.isArray(value)) { |
| 41 | + return `[${value.map(stableStringify).join(",")}]`; |
| 42 | + } |
| 43 | + if (value && typeof value === "object") { |
| 44 | + const record = value as Record<string, unknown>; |
| 45 | + return `{${Object.keys(record) |
| 46 | + .sort() |
| 47 | + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) |
| 48 | + .join(",")}}`; |
| 49 | + } |
| 50 | + return JSON.stringify(value); |
| 51 | +} |
| 52 | + |
| 53 | +// The exact bytes a signature covers: the whole persona with |
| 54 | +// provenance.signature removed. Round-trips through JSON to drop undefined. |
| 55 | +function canonicalBytes(persona: OpenAgentPersona): Buffer { |
| 56 | + const clone = JSON.parse(JSON.stringify(persona)) as OpenAgentPersona; |
| 57 | + if (clone.provenance) { |
| 58 | + delete clone.provenance.signature; |
| 59 | + } |
| 60 | + return Buffer.from(stableStringify(clone), "utf8"); |
| 61 | +} |
| 62 | + |
| 63 | +// ---- did:key address (multicodec ed25519-pub + base58btc) ------------------- |
| 64 | + |
| 65 | +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; |
| 66 | + |
| 67 | +function base58btcEncode(bytes: Buffer): string { |
| 68 | + let zeros = 0; |
| 69 | + while (zeros < bytes.length && bytes[zeros] === 0) { |
| 70 | + zeros += 1; |
| 71 | + } |
| 72 | + const digits: number[] = []; |
| 73 | + for (let index = zeros; index < bytes.length; index += 1) { |
| 74 | + let carry = bytes[index]; |
| 75 | + for (let digitIndex = 0; digitIndex < digits.length; digitIndex += 1) { |
| 76 | + carry += digits[digitIndex] << 8; |
| 77 | + digits[digitIndex] = carry % 58; |
| 78 | + carry = Math.floor(carry / 58); |
| 79 | + } |
| 80 | + while (carry > 0) { |
| 81 | + digits.push(carry % 58); |
| 82 | + carry = Math.floor(carry / 58); |
| 83 | + } |
| 84 | + } |
| 85 | + let out = "1".repeat(zeros); |
| 86 | + for (let index = digits.length - 1; index >= 0; index -= 1) { |
| 87 | + out += BASE58_ALPHABET[digits[index]]; |
| 88 | + } |
| 89 | + return out; |
| 90 | +} |
| 91 | + |
| 92 | +// Raw 32-byte ed25519 public key via JWK export (no hand-parsed SPKI offsets). |
| 93 | +function rawEd25519PublicKey(publicKey: KeyObject): Buffer { |
| 94 | + const jwk = publicKey.export({ format: "jwk" }) as { crv?: string; x?: string }; |
| 95 | + if (jwk.crv !== "Ed25519" || !jwk.x) { |
| 96 | + throw new Error("did:key needs an Ed25519 public key"); |
| 97 | + } |
| 98 | + const raw = Buffer.from(jwk.x, "base64url"); |
| 99 | + if (raw.length !== 32) { |
| 100 | + throw new Error(`unexpected ed25519 key length: ${raw.length}`); |
| 101 | + } |
| 102 | + return raw; |
| 103 | +} |
| 104 | + |
| 105 | +/** Derive the did:key public address for an ed25519 public key. */ |
| 106 | +export function didKeyFromPublicKey(publicKey: KeyObject): string { |
| 107 | + const raw = rawEd25519PublicKey(publicKey); |
| 108 | + const prefixed = Buffer.concat([Buffer.from([0xed, 0x01]), raw]); // 0xed01 = ed25519-pub |
| 109 | + return `did:key:z${base58btcEncode(prefixed)}`; |
| 110 | +} |
| 111 | + |
| 112 | +// ---- signing ---------------------------------------------------------------- |
| 113 | + |
| 114 | +/** |
| 115 | + * Attach a signed ed25519 provenance block to a persona (mutating it in place) |
| 116 | + * and return it alongside the signer's did:key address. Mints a fresh keypair |
| 117 | + * unless one is injected via `opts.keyPair`. |
| 118 | + */ |
| 119 | +export function signPersona( |
| 120 | + persona: OpenAgentPersona, |
| 121 | + opts: SignPersonaOptions = {}, |
| 122 | +): SignPersonaResult { |
| 123 | + const { publicKey, privateKey } = opts.keyPair ?? generateKeyPairSync("ed25519"); |
| 124 | + const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString().trim(); |
| 125 | + |
| 126 | + persona.provenance = { |
| 127 | + created_by: { |
| 128 | + name: persona.name, |
| 129 | + key: publicPem, |
| 130 | + url: "https://github.com/HKUDS/AgentSpace", |
| 131 | + }, |
| 132 | + signed_at: opts.now ?? new Date().toISOString(), |
| 133 | + }; |
| 134 | + |
| 135 | + persona.provenance.signature = edSign(null, canonicalBytes(persona), privateKey).toString("base64"); |
| 136 | + |
| 137 | + return { persona, didKey: didKeyFromPublicKey(publicKey) }; |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * Verify a signed persona's provenance block the same way `@5dive/openagent` |
| 142 | + * does: recompute the canonical bytes (signature removed) and check the ed25519 |
| 143 | + * signature against created_by.key. |
| 144 | + */ |
| 145 | +export function verifyPersonaSignature(persona: OpenAgentPersona): boolean { |
| 146 | + const sig = persona.provenance?.signature; |
| 147 | + const key = persona.provenance?.created_by?.key; |
| 148 | + if (!sig || !key) { |
| 149 | + return false; |
| 150 | + } |
| 151 | + const publicKey = createPublicKey(key); |
| 152 | + return edVerify(null, canonicalBytes(persona), publicKey, Buffer.from(sig, "base64")); |
| 153 | +} |
0 commit comments