Repository navigation
Stop exposing premium trace content through public IPFS CIDs #154
Description
Overview
Full reasoning traces are OracleDesk's paid product. The backend sells 24-hour daily passes (POST /traces/:id/unlock), and the contracts repo's x402 service sells per-trace access (GET /traces/:onChainTraceId behind paymentMiddleware). Every full trace, though, is pinned in plaintext to public IPFS, and the CID is handed to anonymous users:
GET /traces(listTracesinsrc/controllers/trace.controller.ts) selectsipfsCidandtraceHashfor every trace, with no auth.GET /traces/:idwithout a pass returns the free preview fromgetTracWithAccessControl(src/services/subscription.service.ts), which still includesipfsCidnext tolockedFields: ['fullSources', 'hedgeConditions', 'betFraction', 'betSizeUsdc'].- The
REASONING_PUBLISHEDsocket event (src/services/indexer.service.ts) broadcastsipfsCidto every client. reasoning_registry.get_trace(id)returnsipfs_cidto anyone who can simulate a read.
Anyone can fetch https://ipfs.io/ipfs/<cid> and read the "locked" fields for free, so the paywall is cosmetic. Hiding the CID cannot fix this, because the registry publishes it on-chain by design.
This issue makes premium content confidential while keeping the property the product is built on: anyone can verify that a trace's content matches what the agent committed on-chain at decision time. The approach: pin an authenticated encrypted envelope, commit its hash on-chain, keep per-trace keys server-side, and release keys or plaintext only to entitled users.
Background & current behavior
src/services/trace-publish.service.tspinAndPublishTrace({ traceId, payload, action, onChainMarketId }):pinJson(payload)→ipfsCid,traceHash= sha256(pinned bytes) →publishTrace({ trace_hash, ipfs_cid }).verifyTraceOnChainfetches the CID and compares sha256(bytes) with the on-chaintrace_hash.src/services/subscription.service.ts:checkAccess(userId, traceId)finds an ACTIVEDAILY_PASS(any trace) orPER_TRACE(that trace) subscription.getTracWithAccessControlreturns the full DB row when access is granted. The DB row holds the plaintext fields (sourcesUsed,hedgeConditions,betFraction,betSizeUsdc), so the backend can serve them without IPFS.- Contracts repo
x402/trace-api.tsloadVerifiedTracefetches the CID, checksverifyTraceHash, and returns the parsed JSON ascontent. - The frontend's
lib/stellar/trace.tsfetchAndVerifyTrace(cid, onChainHash)fetches from a public gateway and renders the parsed JSON when the hash matches. ReasoningTracehasipfsCid,traceHash,isPublic(unused),verifiedandpreviewSources.
Goals
- New premium traces are pinned only as an encrypted envelope. The plaintext never reaches IPFS.
- Public verifiability: anyone can (a) check that the pinned envelope matches the on-chain
trace_hashand (b) once they have the plaintext, check that it matches theplaintextHashcommitted inside that envelope. - Per-trace data keys, wrapped by a server-side key-encryption key (KEK) with an id, so the KEK can be rotated.
- Entitled users (daily pass, or later a verified x402 purchase) receive the plaintext from the backend, or the per-trace key for client-side decryption, with the plaintext hash check done client-side.
- A free public subset (the preview) is pinned in the clear inside the envelope header, so the free tier needs no key.
Non-goals
- Deleting plaintext already on IPFS, which cannot be recalled.
- Changing the reasoning-registry contract.
- Implementing the x402 side. This issue writes the change request in
docs/contract-requests.mdand keeps the backend compatible. - Encrypting question documents, which are public by design.
Proposed implementation
- Envelope format (
oracledesk.trace.encv1). Canonical JSON so it hashes like everything else:AEAD associated data = canonical bytes of the envelope without{ "kind": "oracledesk.trace.enc", "schemaVersion": 1, "alg": "XChaCha20-Poly1305", "kid": "kek-2026-10", "plaintextHash": "<sha256 hex of canonical plaintext bytes>", "public": { "traceId": "…", "agentType": "MARKET_MAKER", "decisionType": "…", "market": {"backendId": "…", "onChainId": "0"}, "probabilityEstimate": 0.62, "marketProbability": 0.5, "edge": 0.12, "previewSources": [ … ], "createdAt": "…" }, "nonce": "<base64 24 bytes>", "ciphertext": "<base64>" }nonceandciphertext(that is,kind,schemaVersion,alg,kid,plaintextHash,public), so the header cannot be swapped without breaking decryption. The on-chaintrace_hashstayssha256(pinned bytes)= sha256(canonical envelope), which keepsverifyTraceHashandx402/trace-verification.tsworking unchanged on the pinned bytes. src/services/trace-crypto.service.ts(new).Useexport interface SealedTrace { envelope: TraceEnvelopeV1; bytes: Buffer; plaintextHash: string; wrappedKey: Buffer; kid: string } export function sealTrace(plaintext: Buffer, publicPart: TracePublicPart, kek: Kek): SealedTrace; export function openTrace(envelope: TraceEnvelopeV1, dataKey: Buffer): Buffer; // verifies AEAD + plaintextHash export function unwrapKey(wrapped: Buffer, kid: string): Buffer; export interface Kek { kid: string; wrap(dk: Buffer): Buffer; unwrap(w: Buffer): Buffer } export function loadKeks(cfg: Config): Map<string, Kek>;
@noble/ciphers(xchacha20poly1305, audited) for the payload and for wrapping 32-byte random data keys. KEKs come from a newTRACE_KEKSenv var (JSONkid → base64 key, withTRACE_KEK_ACTIVE), validated insrc/config/index.tsand redacted insrc/lib/logger.ts.- Schema (new migration). Add a new table
trace_keys(traceId PK/FK → reasoning_traces ON DELETE CASCADE, kid, wrappedKey BYTEA, plaintextHash CHAR(64), createdAt). AddcontentMode(PLAINTEXT_LEGACY | ENCRYPTED) andplaintextHashtoreasoning_traces. Keeping keys out of the main row stops aselect *leaking them. - Pin path. In
trace-publish.service.ts,pinAndPublishTrace→ whenTRACE_CONFIDENTIAL=true(defaulttruefor new deployments, documented), build the plaintext canonical bytes, thensealTrace→pinJson(envelope)→ storetrace_keysandcontentMode = ENCRYPTEDin the same transaction asipfsCid/traceHash→publishTracewith the envelope hash. Before pinning, decrypt the envelope with the freshly made key and compare bytes, so a broken envelope is never committed. - Read paths.
getTracWithAccessControl: without access, return thepublicsubset plusipfsCid,traceHash(now harmless),contentModeandplaintextHash. With access, also returnverification: { envelopeHash, plaintextHash }so the client can check both hashes.- New
GET /traces/:id/key(requireAuth+checkAccess) returns{ kid, dataKey (base64), plaintextHash }for client-side decryption. Rate-limit it per user and log each release toAgentLog/an audit table (TRACE_KEY_RELEASED). verifyTraceOnChain: still compares the envelope bytes with the on-chain hash. Additionally parse the envelope, checkplaintextHash === trace.plaintextHash, and reportcontentMode.
- x402 coordination. Add a section to
docs/contract-requests.md: x402loadVerifiedTraceshould detectkind: 'oracledesk.trace.enc'and, after payment, obtain the data key from a new backend endpointPOST /internal/traces/:onChainTraceId/keyauthenticated with a shared HMAC or mTLS secret (X402_KEY_RELEASE_SECRET), decrypt it, checkplaintextHash, and return the plaintext. Implement that backend endpoint in this issue behind a feature flag, with replay protection (a timestamp plus nonce in the HMAC). Document indocs/api.mdthat, until x402 ships the change, per-trace x402 purchases of encrypted traces return the envelope only. - Legacy traces. Rows with
ipfsCidand notrace_keysarePLAINTEXT_LEGACY. Shipscripts/unpin-legacy-traces.ts(new, dry-run by default), which lists legacy traces and can call Pinataunpin. State plainly in docs and the script's output that public copies may persist on other nodes and gateways. Do not republish legacy traces encrypted, because the on-chain hash would not match. Mark them in API responses so the frontend can label them "public (legacy)". - Docs. Add a "Confidential traces" section to
docs/architecture.md(threat model: the backend can read every trace, the operator holds the KEK, verifiability is "commit then reveal to payers"), and update the trace object and new endpoints indocs/api.md.
Edge cases & pitfalls
- Trust model change: a third party can verify that the envelope existed on-chain at publish time and, given the plaintext, that it matches. Without paying, they cannot read the content. Say this honestly in the docs. Consider an optional
TRACE_REVEAL_AFTER_RESOLUTIONthat publishes the data key once the market resolves. - Never reuse a data key across traces.
- KEK rotation: new traces use
TRACE_KEK_ACTIVE, and oldkids must stay loadable. Addscripts/rewrap-trace-keys.tsto re-wrap data keys under a new KEK without touching IPFS. - Losing the KEK loses every encrypted trace for paying users. Tie it into the backup runbook.
- Do not log plaintext, data keys or wrapped keys. Extend the pino redaction paths.
canonicalJsonof the envelope must be computed once. Never rebuild it from the DB, because base64 or field-order drift would change the hash.- The preview must not leak locked fields. Build
publicfrom an allowlist, never by deleting keys from the full object.
Testing plan
tests/trace-crypto.test.ts(new):sealTrace then openTrace round-trips and plaintext hashes to plaintextHashtampering with public header or ciphertext fails authenticationenvelope bytes hash equals the trace_hash passed to publishTrace(fakeIpfsTransport+ stubbedpublishTrace)KEK rotation: key wrapped under old kid still unwraps; new traces use active kidpreview response contains no locked fields and no key material
tests/http.test.ts:GET /traces/:id/key requires auth and an active pass,internal key release rejects bad HMAC and replayed nonce.verifyTraceOnChaintests forENCRYPTEDandPLAINTEXT_LEGACYmodes.- Commands:
npx prisma migrate dev --name confidential_traces,npm run build,npm test,npm run check:contracts.
Acceptance criteria
- With
TRACE_CONFIDENTIAL=true, newly pinned traces contain no locked field in plaintext, as shown by a test that scans the pinned bytes. - The on-chain
trace_hashequals sha256 of the pinned envelope, so existing hash verification in the backend and in x402 works unchanged. - Envelopes bind the public header and
plaintextHashthrough AEAD associated data, and tampering is detected. - Per-trace data keys are stored wrapped under a KEK identified by
kid, KEK rotation is supported, and keys never appear in logs or default selects. -
GET /traces/:id/keyreleases keys only to entitled users, with audit logging. - Legacy plaintext traces are labelled, an unpin script exists, and the docs state the limits of unpinning.
-
docs/contract-requests.mddescribes the x402 change, and the backend key-release endpoint exists behind a flag with HMAC and replay protection. -
docs/api.mdanddocs/architecture.mddescribe the format, endpoints and trust model. -
npm run build,npm testandnpm run check:contractspass.
Resources
src/services/trace-publish.service.ts,src/services/ipfs.service.ts,src/services/subscription.service.ts,src/controllers/trace.controller.tssrc/services/indexer.service.ts(REASONING_PUBLISHED),src/lib/logger.ts,src/config/index.ts,prisma/schema.prismadocs/api.md(trace object, x402 section),docs/contract-requests.md,docs/architecture.md- Contracts repo:
x402/trace-api.ts,x402/trace-verification.ts,x402/registry-trace-repository.ts @noble/ciphers: https://github.com/paulmillr/noble-ciphers- XChaCha20-Poly1305 draft: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha
- Envelope encryption: https://cloud.google.com/kms/docs/envelope-encryption
- Pinata unpin API: https://docs.pinata.cloud/api-reference/endpoint/ipfs/unpin-file
Important
Raise your pull request against the develop branch, not main.
All contributor work for this issue must be submitted as a PR into develop. Pull requests opened against main will not be merged and are closed automatically. Create your branch from develop, and before opening the PR double-check that the base branch is set to develop.
Generated by Claude Code
Activity
Ayo2007 commentedon Oct 17, 2026
@Ayo2007 has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @Ayo2007 to this issue.
bade2brazy commentedon Oct 17, 2026
@bade2brazy has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @bade2brazy to this issue.
drips-wave commentedon Oct 17, 2026
Congratulations, @bade2brazy! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.
🧑💻 @bade2brazy: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.
Warning
When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.
🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.
🌊 Happy Wave 🌊
bade2brazy commentedon Oct 17, 2026
Resolved in PR #161
drips-wave commentedon Oct 25, 2026
This issue has been completed by @bade2brazy as part of the Stellar Wave Program's 10th Wave 🥳
😎 @bade2brazy: You earned 200 Points for completing this issue! Once the Wave's rewards are finalized, you'll be eligible for a percentage of the Wave's reward pool based on the percentage of total points you've earned. Learn more here. You can also Leave a review to share your experience working on this issue.
🧑💻 Repo maintainers: How'd the contributor do? Leave a review to share your experience working with them.