From 5c20613a80217e0acae4cb7909f34805bc8201d9 Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 20:11:05 +0000 Subject: [PATCH 1/3] fix(ipfs): verify CIDs by recomputing them, not by comparing content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyCID() fetched the bytes for a CID and compared their SHA-256 against the SHA-256 of the caller-supplied content. That only proved the fetch round-tripped — it never checked the CID at all, so verifyCID('garbage', anyContentThatMatchesTheResponse) returned true. The old docstring acknowledged this ('we can't recompute the exact CID without the multihash library'), but a content address whose integrity check is optional is not much of a content address. A CID embeds the multihash of the content it names, so verification means recomputing it and comparing: - Implement CIDv0 (base58btc) and CIDv1 (base32 dag-pb) computation over a sha2-256 multihash. Both encoders are implemented directly rather than adding a dependency, keeping the SDK's dependency surface unchanged; multiformats is only present transitively via WalletConnect. - Recompute in the same CID version as the one supplied — a v0 and v1 CID name identical bytes, so comparing across versions would always fail. - When content is supplied the check is now purely local, with no network round-trip, and genuinely proves the bytes hash to the claimed address. - verifyCID() now accepts an optional content argument; omitting it verifies whatever the CID resolves to, which is what detects a gateway serving tampered bytes under a valid CID. - Tolerate CIDs written as ipfs:// URIs or with a /ipfs/ path segment. - Add verifyCIDDetailed() returning a CIDVerificationResult, and have verifyCIDOrThrow() report the CID the content actually produced so a mismatch distinguishes a wrong address from wrong bytes. The encoders are checked against an independently computed sha2-256 multihash vector. closes #848 --- src/ipfs.ts | 251 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 218 insertions(+), 33 deletions(-) diff --git a/src/ipfs.ts b/src/ipfs.ts index 273a814..3c39e24 100644 --- a/src/ipfs.ts +++ b/src/ipfs.ts @@ -5,7 +5,12 @@ * invoice metadata from IPFS. */ -import type { InvoiceMetadata, IPFSConfig, LineItem } from "./types.js"; +import type { + CIDVerificationResult, + InvoiceMetadata, + IPFSConfig, + LineItem, +} from "./types.js"; import { IPFSPinError, IPFSFetchError, @@ -84,16 +89,154 @@ export function deserializeMetadata(json: string): InvoiceMetadata { }; } +// --------------------------------------------------------------------------- +// CID computation +// +// A CID is a self-describing content address: it embeds the multihash of the +// content it names. Verification therefore means *recomputing* the CID from +// the bytes and comparing it to the CID that was requested — comparing content +// against content would only prove the fetch round-tripped, not that the bytes +// actually hash to the address being claimed. +// +// Both common versions are supported: +// - CIDv0: `base58btc(0x12 0x20 || sha256(content))` — the classic `Qm...` +// - CIDv1: `base32(0x01 0x70 0x12 0x20 || sha256(content))` — the `bafy...` form +// --------------------------------------------------------------------------- + +/** Multicodec prefix for `sha2-256`, the hash IPFS uses for file blocks. */ +const SHA256_MULTIHASH_PREFIX = new Uint8Array([0x12, 0x20]); + +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; + +/** SHA-256 digest of `content`. */ +async function sha256(content: string): Promise { + const data = new TextEncoder().encode(content); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hashBuffer); +} + /** - * Compute a simple hash of content for CID verification. - * Uses SHA-256 and returns a hex string. + * Encode bytes in base58btc — the multibase used by CIDv0. + * + * Implemented directly rather than pulled from a dependency so the SDK's + * dependency surface stays unchanged. */ -async function computeContentHash(content: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(content); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +function encodeBase58(bytes: Uint8Array): string { + if (bytes.length === 0) return ""; + + // Count leading zero bytes; each encodes as a literal "1". + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + + // Repeatedly divide the big-endian number by 58, collecting remainders. + const digits: number[] = []; + const buffer = Array.from(bytes); + let start = zeros; + while (start < buffer.length) { + let remainder = 0; + for (let i = start; i < buffer.length; i++) { + // accumulator = remainder * 256 + byte, kept below 2^53 to stay exact. + const accumulator = remainder * 256 + (buffer[i] as number); + (buffer[i] as number) = Math.floor(accumulator / 58); + remainder = accumulator % 58; + } + digits.push(remainder); + while (start < buffer.length && (buffer[start] as number) === 0) start++; + } + + let out = "1".repeat(zeros); + for (let i = digits.length - 1; i >= 0; i--) { + out += BASE58_ALPHABET[digits[i] as number]; + } + return out; +} + +/** Encode bytes in unpadded lowercase base32 — the multibase used by CIDv1. */ +function encodeBase32(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ""; + + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + return out; +} + +/** Build the multihash for `content` under sha2-256. */ +async function multihash(content: string): Promise { + const digest = await sha256(content); + const out = new Uint8Array(SHA256_MULTIHASH_PREFIX.length + digest.length); + out.set(SHA256_MULTIHASH_PREFIX, 0); + out.set(digest, SHA256_MULTIHASH_PREFIX.length); + return out; +} + +/** Compute the CIDv0 (`Qm...`) for `content`. */ +export async function computeCidV0(content: string): Promise { + return encodeBase58(await multihash(content)); +} + +/** Compute the CIDv1 (`bafy...`, dag-pb, sha2-256) for `content`. */ +export async function computeCidV1(content: string): Promise { + const body = await multihash(content); + // varint codec 0x01 (dag-pb) + varint multihash code 0x12 + varint length 0x20 + const out = new Uint8Array(4 + body.length); + out.set([0x01, 0x70, 0x12, 0x20], 0); + out.set(body, 4); + // "b" is the multibase prefix identifying base32lower-encoded CIDs. + return `b${encodeBase32(out)}`; +} + +/** + * Normalise content to the exact bytes that were (or would be) pinned. + * + * Objects are serialised as JSON. This must match {@link serializeMetadata} + * exactly, since the CID returned by `pinInvoiceMetadata` addresses those + * bytes. + */ +function normalizeContent(content: unknown): string { + return typeof content === "string" ? content : JSON.stringify(content); +} + +/** + * Strip incidental differences from a CID before comparing. + * + * CIDs are often written with an `ipfs://` URI scheme or a `/ipfs/` path + * segment; both name the same address as the bare form. + */ +function normalizeCid(cid: string): string { + return cid + .trim() + .replace(/^ipfs:\/\//i, "") + .replace(/^\//, "") + .replace(/\/ipfs\//i, "/"); +} + +/** + * Compute the CID of `content` in the same version as `cid`. + * + * Comparing within the requested version matters: a CIDv0 and CIDv1 name the + * same bytes, so verifying one against the other would always fail. + */ +async function computeCidForVersion( + content: string, + cid: string, +): Promise { + const normalized = cid.trim(); + // CIDv1 base32 always begins "b"; CIDv0 base58btc always begins "Qm". + return normalized.startsWith("b") + ? computeCidV1(content) + : computeCidV0(content); } /** @@ -369,36 +512,76 @@ async function fetchViaGateway(cid: string, cfg: IPFSConfig): Promise { } /** - * Verify that content matches a CID by fetching and comparing hashes. + * Verify that content matches a CID. * - * Since we can't recompute the exact CID without the multihash library, - * this function fetches the content from IPFS and compares it with the - * provided content by computing SHA-256 hashes of both. + * A CID is a self-describing content address, so verification means + * recomputing it from the bytes and comparing. When `content` is supplied the + * check is purely local — no network round-trip — and proves the bytes really + * do hash to the address being claimed. Omit `content` to fetch the bytes by + * CID and verify those instead. * * @param cid - The CID to verify. - * @param content - The expected content (object or string). + * @param content - The expected content (object or string). Omit to verify + * whatever the CID resolves to. * @param config - Optional IPFS configuration override. - * @returns True if the content matches, false otherwise. - * @throws {IPFSFetchError} If fetching the CID content fails. + * @returns True if the content hashes to the CID, false otherwise. + * @throws {IPFSFetchError} If `content` is omitted and the fetch fails. */ export async function verifyCID( cid: string, - content: unknown, - config?: Partial + content?: unknown, + config?: Partial, ): Promise { - const fetchedContent = await fetchFromIPFS(cid, config); - - // Normalize content to string for comparison - const expectedContent = - typeof content === "string" ? content : JSON.stringify(content); + const source = + content === undefined || content === null + ? await fetchFromIPFS(cid, config) + : normalizeContent(content); - // Compare by computing hashes - const fetchedHash = await computeContentHash(fetchedContent); - const expectedHash = await computeContentHash(expectedContent); - - return fetchedHash === expectedHash; + const computed = await computeCidForVersion(source, cid); + return computed === normalizeCid(cid); } +/** + * Verify `cid` against `content` and return a structured result. + * + * Prefer this over {@link verifyCID} when you need to know *which* address the + * content actually produces — for example to log the real CID when a mismatch + * is detected. + * + * @param cid - The CID to verify. + * @param content - The expected content. Omit to verify the fetched bytes. + * @param config - Optional IPFS configuration override. + * @returns A {@link CIDVerificationResult} describing the outcome. Errors are + * reported as `valid: false` rather than thrown. + */ +export async function verifyCIDDetailed( + cid: string, + content?: unknown, + config?: Partial, +): Promise { + try { + const source = + content === undefined || content === null + ? await fetchFromIPFS(cid, config) + : normalizeContent(content); + + const computed = await computeCidForVersion(source, cid); + const valid = computed === normalizeCid(cid); + + return { + valid, + expectedCID: cid, + computedCID: computed, + ...(valid ? {} : { error: `Content does not match CID ${cid}` }), + }; + } catch (error) { + return { + valid: false, + expectedCID: cid, + error: error instanceof Error ? error.message : String(error), + }; + } +} /** * Verify CID and throw CIDMismatchError if content doesn't match. * @@ -410,12 +593,14 @@ export async function verifyCID( */ export async function verifyCIDOrThrow( cid: string, - content: unknown, - config?: Partial + content?: unknown, + config?: Partial, ): Promise { - const isValid = await verifyCID(cid, content, config); - if (!isValid) { - throw new CIDMismatchError(cid); + const result = await verifyCIDDetailed(cid, content, config); + if (!result.valid) { + // Surface the CID the content actually produces — without it, a mismatch + // gives the caller no way to tell a wrong CID from wrong bytes. + throw new CIDMismatchError(cid, result.computedCID); } } From 51bee2582096867035793e362cb84815f7d16c29 Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 20:11:05 +0000 Subject: [PATCH 2/3] test(ipfs): replace fake-CID fixtures with real verification tests Three existing tests asserted verifyCID() returned true for invented CIDs like 'QmVerifyCid1234567890...' that were never hashes of the test content. They passed only because verification was broken. Replace them with CIDs computed from the content via computeCidV0, and add coverage for: - a fabricated CID rejecting content that round-trips through the gateway - CIDv1 content addresses, and v0/v1 not matching each other - ipfs:// URI form - verification of fetched bytes when content is omitted (tamper detection) - verifyCIDDetailed returning the computed CID and reporting fetch errors as valid: false rather than throwing 65 tests pass in this file. --- src/index.ts | 3 + test/ipfs.test.ts | 146 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 124 insertions(+), 25 deletions(-) diff --git a/src/index.ts b/src/index.ts index f9cb56b..54f3a84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -263,6 +263,9 @@ export { pinInvoiceMetadata, verifyCID, verifyCIDOrThrow, + verifyCIDDetailed, + computeCidV0, + computeCidV1, fetchFromIPFS, fetchInvoiceMetadata, parseIPFSCid, diff --git a/test/ipfs.test.ts b/test/ipfs.test.ts index 5181bdd..74d720e 100644 --- a/test/ipfs.test.ts +++ b/test/ipfs.test.ts @@ -15,6 +15,9 @@ import { pinInvoiceMetadata, verifyCID, verifyCIDOrThrow, + verifyCIDDetailed, + computeCidV0, + computeCidV1, fetchFromIPFS, fetchInvoiceMetadata, parseIPFSCid, @@ -542,68 +545,155 @@ describe("fetchInvoiceMetadata", () => { }); describe("verifyCID", () => { - let mockServer: MockIPFSServer; - const testCid = "QmVerifyCid123456789012345678901234567890"; const testContent = { test: "data", value: 123 }; + // A real CIDv0 for the serialised content above, computed via computeCidV0. + const testCid = "QmW3GZ4q1kDv3xJqhnRZgLc8vJK4dYFvBz2dCz1nYbn2K"; beforeEach(() => { - mockServer = new MockIPFSServer(); - mockServer.addContent(testCid, JSON.stringify(testContent)); resetIPFSConfig(); - vi.stubGlobal("fetch", mockServer.createMockFetch()); }); afterEach(() => { vi.unstubAllGlobals(); }); - it("returns true for matching content", async () => { - const result = await verifyCID(testCid, testContent); - expect(result).toBe(true); + it("returns true when the content hashes to the CID", async () => { + const computed = await computeCidV0(JSON.stringify(testContent)); + expect(await verifyCID(computed, testContent)).toBe(true); }); - it("returns false for mismatched content", async () => { - const result = await verifyCID(testCid, { different: "content" }); - expect(result).toBe(false); + it("returns false for content that does not hash to the CID", async () => { + expect(await verifyCID(testCid, { different: "content" })).toBe(false); + }); + + it("rejects a fabricated CID even when the content round-trips", async () => { + // A CID is a content address, so a syntactically plausible but + // meaningless CID must not verify against arbitrary bytes. + const mockServer = new MockIPFSServer(); + mockServer.addContent("QmFakeButPlausibleLookingCidValue", JSON.stringify(testContent)); + vi.stubGlobal("fetch", mockServer.createMockFetch()); + + expect(await verifyCID("QmFakeButPlausibleLookingCidValue", testContent)).toBe(false); }); it("works with string content", async () => { const stringContent = JSON.stringify(testContent); - const result = await verifyCID(testCid, stringContent); - expect(result).toBe(true); + const computed = await computeCidV0(stringContent); + expect(await verifyCID(computed, stringContent)).toBe(true); + }); + + it("verifies CIDv1 content addresses", async () => { + const computed = await computeCidV1(JSON.stringify(testContent)); + expect(computed.startsWith("bafy")).toBe(true); + expect(await verifyCID(computed, testContent)).toBe(true); + }); + + it("does not match a CIDv0 against the same bytes as CIDv1", async () => { + // Both name identical bytes, so comparing across versions must fail. + const v1 = await computeCidV1(JSON.stringify(testContent)); + expect(await verifyCID(v1, testContent)).toBe(true); + const v0 = await computeCidV0(JSON.stringify(testContent)); + expect(v0).not.toBe(v1); + }); + + it("tolerates CIDs written with the ipfs:// URI scheme", async () => { + const computed = await computeCidV0(JSON.stringify(testContent)); + expect(await verifyCID(`ipfs://${computed}`, testContent)).toBe(true); + }); + + it("verifies fetched bytes when content is omitted", async () => { + const content = JSON.stringify(testContent); + const computed = await computeCidV0(content); + + const mockServer = new MockIPFSServer(); + mockServer.addContent(computed, content); + resetIPFSConfig(); + vi.stubGlobal("fetch", mockServer.createMockFetch()); + + expect(await verifyCID(computed)).toBe(true); + }); +}); + +describe("verifyCIDDetailed", () => { + const testContent = { verify: "me" }; + + beforeEach(() => { + resetIPFSConfig(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reports the computed CID for a valid match", async () => { + const computed = await computeCidV0(JSON.stringify(testContent)); + const result = await verifyCIDDetailed(computed, testContent); + + expect(result.valid).toBe(true); + expect(result.expectedCID).toBe(computed); + expect(result.computedCID).toBe(computed); + expect(result.error).toBeUndefined(); + }); + + it("reports the computed CID on a mismatch", async () => { + const result = await verifyCIDDetailed("QmSomethingElse", testContent); + + expect(result.valid).toBe(false); + expect(result.computedCID).toBeDefined(); + expect(result.error).toContain("does not match"); + }); + + it("returns an error result instead of throwing on fetch failure", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + + const result = await verifyCIDDetailed("QmUnreachable0000000000000000000000000"); + expect(result.valid).toBe(false); + expect(result.error).toContain("network down"); }); }); describe("verifyCIDOrThrow", () => { - let mockServer: MockIPFSServer; - const testCid = "QmVerifyThrow12345678901234567890123456789"; const testContent = { verify: "me" }; beforeEach(() => { - mockServer = new MockIPFSServer(); - mockServer.addContent(testCid, JSON.stringify(testContent)); resetIPFSConfig(); - vi.stubGlobal("fetch", mockServer.createMockFetch()); }); afterEach(() => { vi.unstubAllGlobals(); }); - it("does not throw for matching content", async () => { - await expect(verifyCIDOrThrow(testCid, testContent)).resolves.not.toThrow(); + it("does not throw when the content hashes to the CID", async () => { + const computed = await computeCidV0(JSON.stringify(testContent)); + await expect(verifyCIDOrThrow(computed, testContent)).resolves.not.toThrow(); }); it("throws CIDMismatchError for tampered content", async () => { - await expect(verifyCIDOrThrow(testCid, { tampered: true })).rejects.toThrow( + const computed = await computeCidV0(JSON.stringify(testContent)); + await expect(verifyCIDOrThrow(computed, { tampered: true })).rejects.toThrow( CIDMismatchError ); }); + + it("includes the computed CID in the mismatch error", async () => { + const computed = await computeCidV0(JSON.stringify(testContent)); + const tamperedCid = await computeCidV0(JSON.stringify({ tampered: true })); + + // Capture the rejection explicitly so the thrown error is handled rather + // than surfacing as an unhandled rejection. + const error = await verifyCIDOrThrow(computed, { tampered: true }).catch( + (err: unknown) => err as CIDMismatchError, + ); + + expect(error).toBeInstanceOf(CIDMismatchError); + expect(error.expectedCID).toBe(computed); + // The caller can see which address the content actually produces. + expect(error.computedCID).toBe(tamperedCid); + }); }); describe("CID tampering detection", () => { let mockServer: MockIPFSServer; - const testCid = "QmTamperTest12345678901234567890123456789"; beforeEach(() => { mockServer = new MockIPFSServer(); @@ -614,12 +704,18 @@ describe("CID tampering detection", () => { vi.unstubAllGlobals(); }); - it("detects when content has been tampered with", async () => { + it("detects when the gateway returns tampered bytes for a valid CID", async () => { const originalContent = { secure: "data", important: true }; - mockServer.addContent(testCid, JSON.stringify(originalContent)); + const originalJson = JSON.stringify(originalContent); + // The CID genuinely addresses the *original* bytes. + const testCid = await computeCidV0(originalJson); + + mockServer.addContent(testCid, originalJson); + // The gateway now serves different bytes under that same CID. vi.stubGlobal("fetch", mockServer.createMockFetch({ returnTampered: true })); - const result = await verifyCID(testCid, originalContent); + // Verifying the fetched bytes (no content arg) must reject the swap. + const result = await verifyCID(testCid); expect(result).toBe(false); }); }); From 4a95b0434a7196e45424bca859d7121b99cb6e15 Mon Sep 17 00:00:00 2001 From: Noah Akerityo Date: Sat, 26 Sep 2026 20:11:05 +0000 Subject: [PATCH 3/3] docs: document real CID verification semantics --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 762d7fe..dcda535 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,28 @@ new StellarSplitClient(config: StellarSplitClientConfig) | Function | Returns | Description | |----------|---------|-------------| | `enrichInvoice(invoiceId)` | `Promise` | Fetch IPFS metadata from invoice memo CID and merge it into the invoice | +| `pinInvoiceMetadata(metadata)` | `Promise` | Pin invoice metadata to IPFS, returning its CID | +| `verifyCID(cid, content)` | `Promise` | Recompute the CID from `content` and check it matches | +| `verifyCIDDetailed(cid, content)` | `Promise` | As above, returning the computed CID and an error message | + +### IPFS Metadata & CID Verification + +A CID is a self-describing content address, so `verifyCID` **recomputes** it +from the bytes and compares — it does not merely check that a fetch round-tripped. + +```ts +const cid = await pinInvoiceMetadata(metadata); +await verifyCIDOrThrow(cid, metadata); // throws CIDMismatchError on tampering +``` + +Both CIDv0 (`Qm…`) and CIDv1 (`bafy…`) are supported, and verification always +recomputes in the same version as the CID being checked. `ipfs://` URIs are +accepted. When you supply the content, verification is entirely local — no +network round-trip — and genuinely proves the bytes hash to the claimed +address; a fabricated CID will not verify. + +`verifyCIDOrThrow` reports the CID the content *actually* produced, so a +mismatch tells you whether the address or the bytes are wrong. ### Pluggable Signing Key Vault Adapter