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 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/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); } } 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); }); });