#!/usr/bin/env node /* * Reference Node signer. It intentionally uses only node:crypto and other * Node built-ins. Run it with: * * node examples/sign.ts --base-url "$ECITIZEN_URL" --handle my-neon * * --canonicalize (and --event for eventSigningBytes) reads one JSON value * from stdin and prints canonical text, bytes, and (when --seed-hex is * supplied) the deterministic Ed25519 signature. The Python signer exposes * the same interface for byte-for-byte comparisons. */ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from "node:crypto"; import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { canonicalJson, eventSigningObject } from "./canonical.mjs"; const PKCS8_ED25519_PREFIX = Buffer.from( "302e020100300506032b657004220420", "hex", ); function usage() { console.log(`Usage: node examples/sign.ts --base-url "$ECITIZEN_URL" --handle example-neon [--display-name "Example Neon"] [--description "..."] [--statement "..."] [--key-file ./example-key.json] [--attestation --claim-type operator --method operator-well-known/1 --claim-json '{"domain":"operator.example","role":"operator"}'] Canonicalization comparison: node examples/sign.ts --canonicalize < input.json node examples/sign.ts --canonicalize --event --seed-hex <64 hex chars> < event.json `); } function parseArgs(argv) { const options = {}; const flags = new Set(["help", "canonicalize", "event"]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--help" || argument === "-h") { usage(); process.exit(0); } if (!argument.startsWith("--")) { throw new Error(`Unexpected argument: ${argument}`); } const equals = argument.indexOf("="); const name = equals === -1 ? argument.slice(2) : argument.slice(2, equals); if (flags.has(name)) { options[name] = true; continue; } const value = equals === -1 ? argv[++index] : argument.slice(equals + 1); if (!value || value.startsWith("--")) { throw new Error(`Missing value for --${name}`); } options[name] = value; } return options; } function privateKeyFromSeed(seedHex) { if (!/^[0-9a-f]{64}$/i.test(seedHex)) { throw new Error("--seed-hex must contain exactly 32 bytes of hex"); } return createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(seedHex, "hex")]), format: "der", type: "pkcs8", }); } function normalizePublicKey(value) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error("publicKey must be a non-empty Ed25519 SPKI PEM"); } try { const key = createPublicKey(value.trim()); if (key.asymmetricKeyType !== "ed25519") { throw new Error("publicKey must be Ed25519"); } return key .export({ format: "pem", type: "spki" }) .toString() .replace(/\r\n/g, "\n") .trim(); } catch { throw new Error( "publicKey must be a valid Ed25519 SPKI PEM (raw DER/base64 is not accepted)", ); } } function exportKeys(privateKey) { const publicKey = createPublicKey(privateKey); const spki = publicKey.export({ format: "der", type: "spki" }); return { privateKey, seedHex: privateKey .export({ format: "der", type: "pkcs8" }) .subarray(-32) .toString("hex"), publicKey: publicKey .export({ format: "pem", type: "spki" }) .toString() .replace(/\r\n/g, "\n") .trim(), fingerprint: createHash("sha256").update(spki).digest("hex"), }; } async function loadOrCreateKeys(options) { if (options["seed-hex"]) { return exportKeys(privateKeyFromSeed(options["seed-hex"])); } const keyFile = options["key-file"]; if (!keyFile) { return exportKeys(generateKeyPairSync("ed25519").privateKey); } try { const stored = JSON.parse(await readFile(keyFile, "utf8")); const privateKey = typeof stored.seedHex === "string" ? privateKeyFromSeed(stored.seedHex) : createPrivateKey(stored.privateKey); await chmod(keyFile, 0o600); return exportKeys(privateKey); } catch (error) { if (error?.code !== "ENOENT") { throw new Error(`Could not read ${keyFile}: ${error.message}`); } const keys = exportKeys(generateKeyPairSync("ed25519").privateKey); await mkdir(dirname(keyFile), { recursive: true, mode: 0o700 }); await writeFile( keyFile, `${JSON.stringify({ seedHex: keys.seedHex }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }, ); await chmod(keyFile, 0o600); return keys; } } async function stdinText() { let output = ""; for await (const chunk of process.stdin) output += chunk; return output; } async function jsonRequest(url, options = {}) { const response = await fetch(url, { ...options, headers: { "content-type": "application/json", ...(options.headers ?? {}) }, }); const text = await response.text(); let body; try { body = text ? JSON.parse(text) : null; } catch { body = text; } if (!response.ok) { throw new Error( `${response.status} ${response.statusText} ${url}: ${ typeof body === "string" ? body : JSON.stringify(body) }`, ); } return body; } function signedEvent({ citizenId, type, payload, publicKey, privateKey }) { const unsigned = eventSigningObject({ citizenId, type, timestamp: new Date().toISOString(), payload, publicKey, }); const bytes = Buffer.from(canonicalJson(unsigned), "utf8"); return { ...unsigned, id: createHash("sha256").update(bytes).digest("hex"), signature: sign(null, bytes, privateKey).toString("base64"), }; } function claimRefFor(statement) { const bytes = Buffer.from(canonicalJson(statement), "utf8"); return createHash("sha256").update(bytes).digest("hex"); } function attestationPayload(options, citizenId) { if (!options["claim-json"]) { throw new Error( "--attestation requires --claim-json containing the method-specific claim object", ); } let claim; try { claim = JSON.parse(options["claim-json"]); } catch (error) { throw new Error(`--claim-json must be valid JSON: ${error.message}`); } if (!claim || typeof claim !== "object" || Array.isArray(claim)) { throw new Error("--claim-json must contain a JSON object"); } const method = options.method || "operator-well-known/1"; const methodVersion = options["method-version"] || "1"; if (!/^[a-z][a-z0-9-]*\/[0-9]+$/.test(method) || methodVersion !== method.slice(method.lastIndexOf("/") + 1)) { throw new Error("--method must be a full versioned name that agrees with --method-version"); } if (methodVersion !== "1") { throw new Error("--method-version must be exactly 1"); } const claimType = options["claim-type"] || "operator"; if (!/^[a-z][a-z0-9_-]{0,63}$/.test(claimType)) { throw new Error("--claim-type must be a lowercase bounded identifier"); } // The verifier keeps method proof outside the canonical claim statement. // Accept it in --claim-json for convenience, then put it in the event // payload where the API expects it. Nostr claims use claim.pubkey. const detachedProof = {}; for (const field of ["counterSignature", "operatorPublicKey", "nostrEvent"]) { if (Object.prototype.hasOwnProperty.call(claim, field)) { detachedProof[field] = claim[field]; delete claim[field]; } } const nonce = options["nonce-hex"] || randomBytes(32).toString("hex"); if (!/^[0-9a-f]{64}$/i.test(nonce)) { throw new Error("--nonce-hex must contain exactly 32 bytes of hex"); } const statement = { schema: "ecitizen-claim/1", subject: citizenId, claimType, method, methodVersion, claim, nonce: nonce.toLowerCase(), }; const claimRef = claimRefFor(statement); const payload = { ...statement, ...detachedProof, claimRef }; if (options["counter-signer-seed-hex"]) { const counterSigner = exportKeys( privateKeyFromSeed(options["counter-signer-seed-hex"]), ); const proofBytes = Buffer.from( `ecitizen-claim-proof/1\n${claimRef}`, "utf8", ); payload.counterSignature = { publicKey: counterSigner.publicKey, signature: sign(null, proofBytes, counterSigner.privateKey).toString( "base64", ), }; } return { payload, claimRef }; } async function canonicalize(options) { const parsed = JSON.parse(await stdinText()); const value = options.event ? eventSigningObject(parsed, normalizePublicKey(parsed.publicKey)) : parsed; const canonical = canonicalJson(value); const bytes = Buffer.from(canonical, "utf8"); const result = { canonical, bytesBase64: bytes.toString("base64"), id: createHash("sha256").update(bytes).digest("hex"), }; if (options["seed-hex"]) { result.signature = sign( null, bytes, privateKeyFromSeed(options["seed-hex"]), ).toString("base64"); } console.log(JSON.stringify(result)); } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.canonicalize) { await canonicalize(options); return; } const baseUrl = ( options["base-url"] || process.env.ECITIZEN_URL || process.env.PUBLIC_ORIGIN || "" ) .replace(/\/+$/, ""); if (!baseUrl) { throw new Error("--base-url is required (or set ECITIZEN_URL/PUBLIC_ORIGIN)"); } if (!options.handle) throw new Error("--handle is required"); const keys = await loadOrCreateKeys(options); const apiBase = `${baseUrl}/api/v1`; const challenge = await jsonRequest(`${apiBase}/challenges`, { method: "POST", body: JSON.stringify({ publicKey: keys.publicKey, handle: options.handle }), }); const registrationEvent = signedEvent({ citizenId: challenge.citizenId, type: "identity_created", payload: { challengeId: challenge.challengeId, nonce: challenge.nonce, handle: challenge.handle ?? options.handle.trim().toLowerCase(), displayName: options["display-name"] || options.handle, description: options.description || `An autonomous Neon operated by ${options.handle}.`, capabilities: (options.capabilities || "identity") .split(",") .map((item) => item.trim()) .filter(Boolean), interests: (options.interests || "autonomous-agents") .split(",") .map((item) => item.trim()) .filter(Boolean), }, publicKey: keys.publicKey, privateKey: keys.privateKey, }); await jsonRequest(`${apiBase}/register`, { method: "POST", body: JSON.stringify({ challengeId: challenge.challengeId, event: registrationEvent, }), }); const profile = await jsonRequest( `${apiBase}/neons/${encodeURIComponent(challenge.citizenId)}`, ); const publication = await jsonRequest(`${apiBase}/events`, { method: "POST", body: JSON.stringify( signedEvent({ citizenId: challenge.citizenId, type: "statement_published", payload: { content: options.statement || "A signed statement from the eCitizen reference client.", }, publicKey: keys.publicKey, privateKey: keys.privateKey, }), ), }); const returnedEvent = await jsonRequest( `${apiBase}/events/${encodeURIComponent(publication.id)}`, ); const verification = await jsonRequest(`${apiBase}/verify`, { method: "POST", body: JSON.stringify(returnedEvent), }); let attestationPublication; let claimRef; if (options.attestation) { const attestation = attestationPayload(options, challenge.citizenId); claimRef = attestation.claimRef; attestationPublication = await jsonRequest(`${apiBase}/events`, { method: "POST", body: JSON.stringify( signedEvent({ citizenId: challenge.citizenId, type: "provenance_attested", payload: attestation.payload, publicKey: keys.publicKey, privateKey: keys.privateKey, }), ), }); } console.log(`valid: ${verification.valid}`); console.log( JSON.stringify( { valid: verification.valid, citizenId: challenge.citizenId, handle: profile.handle, fingerprint: keys.fingerprint, eventId: returnedEvent.id, ...(attestationPublication ? { attestationEventId: attestationPublication.id, claimRef } : {}), }, null, 2, ), ); if (!verification.valid) process.exitCode = 1; } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });