diff --git a/.gitignore b/.gitignore index 3ee505e..570081c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ verify.out.json .env .env.local .env.*.local +.local/ diff --git a/docs/proof/manual-third-party-signer-proof.md b/docs/proof/manual-third-party-signer-proof.md new file mode 100644 index 0000000..633a683 --- /dev/null +++ b/docs/proof/manual-third-party-signer-proof.md @@ -0,0 +1,78 @@ +# Manual third-party signer proof + +## What this proves + +A non-runtime agent identity can sign a CommandLayer action receipt locally, and the existing public verifier can verify it by resolving that agent’s ENS TXT public-key records. + +## What this does not prove + +* automated tenant onboarding is complete +* ENS TXT records are automatically published during claim flow +* CommandLayer holds tenant private keys +* full receipt chain continuity is enforced +* platform Genesis receipts are tenant-signed + +## Procedure + +1. Choose a controlled signer name, example: + `proof.approveagent.eth` +2. Run identity generation command: + + ```bash + TENANT_AGENT_ENS='proof.approveagent.eth' node scripts/manual-tenant-proof.cjs generate + ``` + + This writes the local identity package under `.local/tenant-proof//`. The private key remains local and must never be published, uploaded, or committed. +3. Publish the four generated TXT records in ENS Manager for that exact signer name. + + The four TXT records also appear in: + + ```text + .local/tenant-proof//ens-records.txt + ``` + + They have this shape: + + ```text + cl.sig.pub=ed25519: + cl.sig.kid= + cl.sig.canonical=json.sorted_keys.v1 + cl.receipt.signer= + ``` +4. Verify the TXT records are live using the same production resolver path where possible. The verifier must be able to resolve all four records from the chosen signer name before the signed receipt can verify. +5. Run signing command: + + ```bash + TENANT_AGENT_ENS='proof.approveagent.eth' node scripts/manual-tenant-proof.cjs sign + ``` + + This writes the signed local receipt to: + + ```text + .local/tenant-proof//signed-approve-receipt.json + ``` +6. Paste the generated receipt JSON into: + `/verify.html` +7. Confirm expected successful output: + + * `status: VERIFIED` + * `signer: proof.approveagent.eth` + * `public_key_source: ens_txt` + * `ens_resolved: true` + * `hash_matches: true` + * `signature_valid: true` + * `key_id: ` + +## Negative tests + +1. Change `output.decision` after signing → expect `INVALID`. +2. Change top-level signer to `runtime.commandlayer.eth` without resigning → expect `INVALID`. +3. Change `metadata.proof.signature.kid` → expect `INVALID`. + +## Evidence capture + +Capture screenshots of: + +* ENS TXT records configured for the chosen signer name, without any private key visible. +* Public verifier returning `VERIFIED` for the tenant-signed receipt. +* Public verifier returning `INVALID` after tampering. diff --git a/scripts/manual-tenant-proof.cjs b/scripts/manual-tenant-proof.cjs new file mode 100755 index 0000000..05151d4 --- /dev/null +++ b/scripts/manual-tenant-proof.cjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { signReceipt } = require('../lib/receiptSigning'); + +const CANONICALIZATION = 'json.sorted_keys.v1'; +const DEFAULT_OUTPUT_ROOT = path.join(process.cwd(), '.local', 'tenant-proof'); +const PRIVATE_KEY_FILE = 'private-key.pkcs8.pem'; +const ENS_RECORDS_FILE = 'ens-records.txt'; +const IDENTITY_FILE = 'identity.json'; +const SIGNED_RECEIPT_FILE = 'signed-approve-receipt.json'; + +function requireTenantEns(env = process.env) { + const signer = String(env.TENANT_AGENT_ENS || '').trim(); + if (!signer) { + throw new Error('TENANT_AGENT_ENS is required, for example TENANT_AGENT_ENS=proof.approveagent.eth'); + } + if (signer.includes('/') || signer.includes('\\') || signer === '.' || signer === '..') { + throw new Error('TENANT_AGENT_ENS must be an ENS name, not a path.'); + } + return signer; +} + +function outputDirForSigner(signer, outputRoot = DEFAULT_OUTPUT_ROOT) { + return path.join(outputRoot, signer); +} + +function publicKeyRawBase64FromPrivatePem(privateKeyPem) { + const privateKey = crypto.createPrivateKey(privateKeyPem); + if (privateKey.asymmetricKeyType !== 'ed25519') { + throw new Error('Private key must be an Ed25519 PKCS#8 PEM key.'); + } + const publicKey = crypto.createPublicKey(privateKey); + const spkiDer = publicKey.export({ type: 'spki', format: 'der' }); + return Buffer.from(spkiDer.subarray(-32)).toString('base64'); +} + +function normalizePemValue(value) { + return String(value).replace(/\\n/g, '\n').trim(); +} + +function readExplicitPrivateKey(env = process.env) { + if (env.TENANT_AGENT_PRIVATE_KEY_PKCS8_PEM) { + return normalizePemValue(env.TENANT_AGENT_PRIVATE_KEY_PKCS8_PEM); + } + if (env.TENANT_AGENT_PRIVATE_KEY_PKCS8_PEM_B64) { + return Buffer.from(env.TENANT_AGENT_PRIVATE_KEY_PKCS8_PEM_B64, 'base64').toString('utf8').trim(); + } + if (env.TENANT_AGENT_PRIVATE_KEY_PKCS8_PEM_FILE) { + return fs.readFileSync(env.TENANT_AGENT_PRIVATE_KEY_PKCS8_PEM_FILE, 'utf8').trim(); + } + return null; +} + +function generateKid() { + return crypto.randomBytes(12).toString('base64url'); +} + +function makeEnsRecords({ signer, publicKeyBase64, kid }) { + return { + 'cl.sig.pub': `ed25519:${publicKeyBase64}`, + 'cl.sig.kid': kid, + 'cl.sig.canonical': CANONICALIZATION, + 'cl.receipt.signer': signer, + }; +} + +function formatEnsRecords(records) { + return [ + `cl.sig.pub=${records['cl.sig.pub']}`, + `cl.sig.kid=${records['cl.sig.kid']}`, + `cl.sig.canonical=${records['cl.sig.canonical']}`, + `cl.receipt.signer=${records['cl.receipt.signer']}`, + ].join('\n'); +} + +function makeIdentity({ signer, publicKeyBase64, kid, privateKeyPath }) { + const records = makeEnsRecords({ signer, publicKeyBase64, kid }); + return { + signer, + kid, + public_key_alg: 'Ed25519', + public_key_raw_base64: publicKeyBase64, + canonicalization: CANONICALIZATION, + private_key_path: privateKeyPath, + ens_records: records, + }; +} + +function generateKeyPackage({ signer = requireTenantEns(), outputRoot = DEFAULT_OUTPUT_ROOT, env = process.env } = {}) { + let privateKeyPem = readExplicitPrivateKey(env); + let publicKeyBase64; + + if (privateKeyPem) { + publicKeyBase64 = publicKeyRawBase64FromPrivatePem(privateKeyPem); + } else { + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + publicKeyBase64 = publicKeyRawBase64FromPrivatePem(privateKeyPem); + } + + const kid = generateKid(); + const outDir = outputDirForSigner(signer, outputRoot); + const privateKeyPath = path.join(outDir, PRIVATE_KEY_FILE); + const ensRecordsPath = path.join(outDir, ENS_RECORDS_FILE); + const identityPath = path.join(outDir, IDENTITY_FILE); + const identity = makeIdentity({ signer, publicKeyBase64, kid, privateKeyPath }); + const ensRecordsText = formatEnsRecords(identity.ens_records); + + fs.mkdirSync(outDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(privateKeyPath, privateKeyPem.trimEnd() + '\n', { mode: 0o600 }); + fs.writeFileSync(ensRecordsPath, ensRecordsText + '\n', 'utf8'); + fs.writeFileSync(identityPath, `${JSON.stringify(identity, null, 2)}\n`, 'utf8'); + + return { signer, kid, publicKeyBase64, records: identity.ens_records, outDir, privateKeyPath, ensRecordsPath, identityPath }; +} + +function makeTenantApproveReceipt(signer, now = new Date()) { + return { + signer, + verb: 'approve', + input: { + request_id: 'tenant-proof-001', + action: 'approve quoted work', + }, + output: { + decision: 'approved', + }, + execution: { + status: 'ok', + mode: 'tenant-signed-local-proof', + }, + ts: now.toISOString(), + }; +} + +function assertNoPrivateKeyMaterial(receiptJson) { + if (/BEGIN PRIVATE KEY|END PRIVATE KEY|PRIVATE KEY/i.test(receiptJson)) { + throw new Error('Refusing to emit signed receipt: private key material was detected in receipt JSON.'); + } +} + +async function signTenantReceipt({ signer = requireTenantEns(), outputRoot = DEFAULT_OUTPUT_ROOT, now = new Date() } = {}) { + const outDir = outputDirForSigner(signer, outputRoot); + const privateKeyPath = path.join(outDir, PRIVATE_KEY_FILE); + const identityPath = path.join(outDir, IDENTITY_FILE); + const signedReceiptPath = path.join(outDir, SIGNED_RECEIPT_FILE); + + const identity = JSON.parse(fs.readFileSync(identityPath, 'utf8')); + const privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8'); + if (identity.signer !== signer) { + throw new Error(`Identity package signer ${identity.signer} does not match TENANT_AGENT_ENS ${signer}.`); + } + + const unsignedReceipt = makeTenantApproveReceipt(signer, now); + const signedReceipt = await signReceipt(unsignedReceipt, { + signerId: signer, + kid: identity.kid, + privateKeyPem, + }); + + if (signedReceipt.metadata?.proof?.signature?.role === 'runtime') { + delete signedReceipt.metadata.proof.signature.role; + } + + const receiptJson = `${JSON.stringify(signedReceipt, null, 2)}\n`; + assertNoPrivateKeyMaterial(receiptJson); + fs.mkdirSync(outDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(signedReceiptPath, receiptJson, 'utf8'); + + return { signer, kid: identity.kid, signedReceipt, signedReceiptPath }; +} + +function printGenerated(result) { + console.log(`Signer ENS: ${result.signer}`); + console.log(`Key ID: ${result.kid}`); + console.log(`Public key (raw base64): ${result.publicKeyBase64}`); + console.log('ENS TXT records:'); + console.log(formatEnsRecords(result.records)); + console.log(`Private key written locally: ${result.privateKeyPath}`); + console.log('Do not publish, upload, or commit the private key. Only publish the four TXT record values above.'); +} + +function printSigned(result) { + console.log(JSON.stringify(result.signedReceipt, null, 2)); + console.error(`Signed receipt written locally: ${result.signedReceiptPath}`); + console.error('No private key material is included in the signed receipt. Do not upload the private key.'); +} + +async function main(argv = process.argv.slice(2)) { + const mode = argv[0]; + if (mode === 'generate') { + printGenerated(generateKeyPackage()); + return; + } + if (mode === 'sign') { + printSigned(await signTenantReceipt()); + return; + } + throw new Error('Usage: TENANT_AGENT_ENS=proof.approveagent.eth node scripts/manual-tenant-proof.cjs '); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err.message); + process.exitCode = 1; + }); +} + +module.exports = { + CANONICALIZATION, + PRIVATE_KEY_FILE, + ENS_RECORDS_FILE, + IDENTITY_FILE, + SIGNED_RECEIPT_FILE, + requireTenantEns, + outputDirForSigner, + publicKeyRawBase64FromPrivatePem, + generateKeyPackage, + signTenantReceipt, + makeEnsRecords, + formatEnsRecords, + makeTenantApproveReceipt, + assertNoPrivateKeyMaterial, +}; diff --git a/tests/manual-tenant-proof.test.js b/tests/manual-tenant-proof.test.js new file mode 100644 index 0000000..b337877 --- /dev/null +++ b/tests/manual-tenant-proof.test.js @@ -0,0 +1,115 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { verifyReceipt } = require('../lib/verifyReceipt'); +const { + generateKeyPackage, + signTenantReceipt, + formatEnsRecords, + ENS_RECORDS_FILE, + SIGNED_RECEIPT_FILE, +} = require('../scripts/manual-tenant-proof.cjs'); + +const TENANT_SIGNER = 'proof.approveagent.eth'; +const FIXED_NOW = new Date('2026-05-29T00:00:00.000Z'); + +function makeTempOutputRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'commandlayer-tenant-proof-')); +} + +async function makeSignedProof() { + const outputRoot = makeTempOutputRoot(); + const generated = generateKeyPackage({ signer: TENANT_SIGNER, outputRoot, env: {} }); + const signed = await signTenantReceipt({ signer: TENANT_SIGNER, outputRoot, now: FIXED_NOW }); + return { outputRoot, generated, signed }; +} + +function makeTextResolver(records) { + return async (_name, key) => records[key] || null; +} + +test('generated ENS record package includes all four exact required records', () => { + const outputRoot = makeTempOutputRoot(); + const generated = generateKeyPackage({ signer: TENANT_SIGNER, outputRoot, env: {} }); + const ensRecordsPath = path.join(generated.outDir, ENS_RECORDS_FILE); + const ensRecordsText = fs.readFileSync(ensRecordsPath, 'utf8'); + + assert.equal(ensRecordsText, `${formatEnsRecords(generated.records)}\n`); + assert.deepEqual(Object.keys(generated.records), [ + 'cl.sig.pub', + 'cl.sig.kid', + 'cl.sig.canonical', + 'cl.receipt.signer', + ]); + assert.match(ensRecordsText, /^cl\.sig\.pub=ed25519:[A-Za-z0-9+/=]+$/m); + assert.match(ensRecordsText, /^cl\.sig\.kid=.+$/m); + assert.match(ensRecordsText, /^cl\.sig\.canonical=json\.sorted_keys\.v1$/m); + assert.match(ensRecordsText, new RegExp(`^cl\\.receipt\\.signer=${TENANT_SIGNER}$`, 'm')); +}); + +test('signed receipt uses configured tenant ENS signer and tenant proof metadata', async () => { + const { generated, signed } = await makeSignedProof(); + const receipt = signed.signedReceipt; + + assert.equal(receipt.signer, TENANT_SIGNER); + assert.equal(receipt.metadata.proof.signer_id, TENANT_SIGNER); + assert.equal(receipt.metadata.proof.signature.kid, generated.kid); + assert.equal(receipt.metadata.proof.canonicalization, 'json.sorted_keys.v1'); + assert.equal(receipt.metadata.proof.signature.role, undefined); + assert.equal(receipt.verb, 'approve'); +}); + +test('signed receipt verifies through existing verifyReceipt with tenant ENS TXT fixture', async () => { + const { generated, signed } = await makeSignedProof(); + const out = await verifyReceipt(signed.signedReceipt, { + ens: { textResolver: makeTextResolver(generated.records), allowLocalFallback: false }, + }); + + assert.equal(out.status, 'VERIFIED'); + assert.equal(out.signer, TENANT_SIGNER); + assert.equal(out.public_key_source, 'ens_txt'); + assert.equal(out.ens_resolved, true); + assert.equal(out.hash_matches, true); + assert.equal(out.signature_valid, true); + assert.equal(out.key_id, generated.kid); +}); + +test('mismatched signer returns INVALID', async () => { + const { generated, signed } = await makeSignedProof(); + const tampered = structuredClone(signed.signedReceipt); + tampered.signer = 'runtime.commandlayer.eth'; + + const out = await verifyReceipt(tampered, { + ens: { textResolver: makeTextResolver(generated.records), allowLocalFallback: false }, + }); + + assert.equal(out.status, 'INVALID'); +}); + +test('mismatched kid returns INVALID', async () => { + const { generated, signed } = await makeSignedProof(); + const tampered = structuredClone(signed.signedReceipt); + tampered.metadata.proof.signature.kid = 'wrong-kid'; + + const out = await verifyReceipt(tampered, { + ens: { textResolver: makeTextResolver(generated.records), allowLocalFallback: false }, + }); + + assert.equal(out.status, 'INVALID'); + assert.equal(out.debug.key_id_matched, false); +}); + +test('generated signed receipt output never includes private key material', async () => { + const { signed } = await makeSignedProof(); + const receiptJson = JSON.stringify(signed.signedReceipt, null, 2); + const writtenReceipt = fs.readFileSync(signed.signedReceiptPath, 'utf8'); + + assert.doesNotMatch(receiptJson, /BEGIN PRIVATE KEY|END PRIVATE KEY|PRIVATE KEY/i); + assert.doesNotMatch(writtenReceipt, /BEGIN PRIVATE KEY|END PRIVATE KEY|PRIVATE KEY/i); + assert.equal(path.basename(signed.signedReceiptPath), SIGNED_RECEIPT_FILE); +});