Your AI Chatbot Said What? Solving the Two-Party Proof Problem with Dual-Secret Verification
Source: Dev.to
The Two‑Party Proof Problem
When your AI chatbot tells a user that their insurance claim is approved, and the user later claims the chatbot said something different – who’s right?
Neither side can prove anything. The request goes to an AI provider (e.g., OpenAI), the response comes back, and both parties are left with only screenshots and trust.
This is the two‑party proof problem, and it only gets worse as AI interactions become more consequential.
Introducing IOProof
IOProof is an open‑source proxy that sits between your service and any AI API, capturing and cryptographically attesting every interaction.
The key innovation is the dual‑secret verification model – the mechanism that makes it useful when two parties disagree.
What IOProof does
- Captures the raw bytes of both request and response.
- Hashes everything with SHA‑256.
- Generates two independent cryptographic secrets (owner secret & user secret).
- Blinds the proof and batches it into a Merkle tree.
- Commits the Merkle root to Solana.
Hashing Core (deliberately simple)
const crypto = require('crypto');
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
function buildCombinedHash(requestHash, responseHash, timestamp) {
const payload = `${requestHash}|${responseHash}|${timestamp}`;
return sha256(Buffer.from(payload, 'utf-8'));
}
The combined hash binds the request, response, and timestamp together. Changing a single byte anywhere yields a completely different hash – textbook content‑addressable integrity.
Dual‑Secret Generation
The first version of IOProof generated one secret per proof, which created a trust bottleneck.
The fix is to generate two independent secrets at proof creation:
function generateSecret() {
return crypto.randomBytes(32).toString('hex');
}
// In the proxy route:
const secret = generateSecret(); // owner secret (for the API caller)
const userSecret = generateSecret(); // user secret (for the end‑user)
const blindedHash = blindHash(combinedHash, secret);
Both secrets unlock identical proof details (full request/response payloads, all hashes, the Merkle proof, the Solana transaction). They are verified through different cryptographic mechanisms, which matters.
Owner secret – verified via blinding
function blindHash(combinedHash, secret) {
return sha256(Buffer.from(`${combinedHash}|${secret}`, 'utf-8'));
}
At verification time the server re‑derives the blinded hash from the supplied secret.
If SHA‑256(combined_hash | secret) === stored_blinded_hash, the owner is authenticated. No stored secret is needed – just math.
User secret – verified via constant‑time comparison
function safeEqual(a, b) {
if (!a || !b || a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
Why timingSafeEqual?
A naive === comparison short‑circuits on the first differing byte, leaking information about how many leading characters matched (a classic timing side‑channel). crypto.timingSafeEqual always takes the same amount of time, preventing an attacker from brute‑forcing the secret byte‑by‑byte.
Verification endpoint logic
let secretValid = false;
let accessType = null;
// Owner: re‑derive the blinded hash
const expectedBlinded = blindHash(proof.combinedHash, secret);
if (expectedBlinded === proof.blindedHash) {
secretValid = true;
accessType = 'owner';
}
// User: constant‑time comparison against stored secret
if (!secretValid && proof.userSecret && safeEqual(secret, proof.userSecret)) {
secretValid = true;
accessType = 'user';
}
// Response includes which party succeeded
res.json({ secretValid, access_type: accessType, proof });
Both parties receive the same proof data, but they never need to trust each other or coordinate access.
Merkle‑Tree Batching (saving on‑chain costs)
Writing a Solana transaction for every API call would be expensive and slow.
Instead, IOProof batches pending proofs into a Merkle tree and commits only the root:
function buildMerkleTree(leaves) {
if (leaves.length === 0) return { root: null, layers: [] };
if (leaves.length === 1) return { root: leaves[0], layers: [leaves] };
const layers = [leaves.slice()];
let currentLayer = leaves.slice();
while (currentLayer.length > 1) {
const nextLayer = [];
for (let i = 0; i < currentLayer.length; i += 2) {
const left = currentLayer[i];
const right = currentLayer[i + 1] || left;
nextLayer.push(sha256(Buffer.from(left + right, 'utf-8')));
}
layers.push(nextLayer);
currentLayer = nextLayer;
}
return { root: currentLayer[0], layers };
}
Hosted Free Tier
- Quota: 100 proofs per month
- Sign‑up:
Installation
npm install ioproof # v0.2.0
- The entire proxy runs as a single Node.js process.
- Point your existing API calls at it, and every interaction becomes independently verifiable by both parties.
- The code is MIT‑licensed—read it, fork it, poke holes in it.
Why Use IOProof?
If you are building anything where AI outputs have real‑world consequences—customer support, medical triage, financial advice, legal research—the question isn’t whether you need audit trails. It’s whether both sides should be able to prove what happened without trusting each other.