diff --git a/CHANGELOG.md b/CHANGELOG.md index c4bfc50f..67d89383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog -## Unreleased +## 1.1.1 +- Fix IPFS subdomain gateway resolution and percent-encoded URI paths, with traversal validation. +- Bound combined scrypt wallet work and temporary memory, and enforce the 256 MiB backend memory limit. - Return go-core-compatible 56-byte X448 shared secrets from `SigningKey.computeSharedSecret`, replacing the previous 57-byte Edwards encoding. - Import go-core PBKDF2-SHA3-256 keystores while preserving MAC-validated access to historical CoreBC SHA2 keystores. - Add independent shared-secret and keystore compatibility vectors for Node and browser builds. diff --git a/README.md b/README.md index 43685c86..ca5ab739 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,11 @@ const signature = await wallet.signMessage("Hello Core"); ``` Wallets support mnemonics, seed derivation, and encrypted JSON keystores. Wallet -KDF limits match Core Web3Dart: PBKDF2 permits up to 10 million iterations; -scrypt permits `N <= 1048576`, `r * p <= 1048576`, and at most 256 MiB of -estimated memory. Wallets exceeding these limits are rejected. Private keys stay +KDF limits allow PBKDF2 up to 10 million iterations; +scrypt permits `N <= 1048576`, `r * p <= 1048576`, `N * r * p <= 2097152` +(twice the default wallet work), and `128 * r * (N + p + 1) <= 256 MiB` +including parallel and scratch buffers. The built-in scrypt backend also enforces +the 256 MiB temporary memory limit. Wallets exceeding these limits are rejected. Private keys stay with the caller. Core uses 57-byte Ed448 private and public keys and network-prefixed addresses; Ethereum secp256k1 keys are not interchangeable. Keep private keys and recovery phrases out of logs and source control. @@ -100,7 +102,11 @@ structure, not issuer authenticity or gateway content integrity. and HTTP(S) URLs. Its default template is `https://ipf.sk/{cid}`; configure another gateway with `new IpfsGateway({ template: "https://gateway.example/ipfs/{cid}" })`. JSON -reads default to a 1 MiB limit and 30-second timeout. Treat metadata URLs as +reads default to a 1 MiB limit and 30-second timeout. Subdomain templates such as +`https://{cid}.ipfs.dweb.link` are also supported. In `ipfs://` URIs, path +segments are percent-decoded once and re-encoded; escaped separators stay within +their segment and dot segments are rejected. Bare and `/ipfs/` paths are literal +text (a literal `%` becomes `%25`). Treat metadata URLs as untrusted input; server applications should supply a `fetch` implementation that enforces their outbound network policy. diff --git a/package.json b/package.json index b6a5b555..1fc35a0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corebc", - "version": "1.1.0", + "version": "1.1.1", "author": "CorePass", "description": "A complete and compact CoreBC library, for Dapps, wallets and any other tools.", "browser": { diff --git a/src.ts/_tests/test-cip.ts b/src.ts/_tests/test-cip.ts index b6109ce7..062bf2c6 100644 --- a/src.ts/_tests/test-cip.ts +++ b/src.ts/_tests/test-cip.ts @@ -74,6 +74,7 @@ describe("CIP metadata and custom units", () => { assert.equal((await metadata.readAll(42, false))[0].sealed, undefined); assert.equal((await metadata.readLifecycle(42)).tokenExpiration, 100n); const gateway = new IpfsGateway({ + template: "https://{cid}.ipfs.dweb.link", fetch: async () => new Response('{"pH":{"value":7}}'), }); assert.equal( @@ -139,6 +140,49 @@ describe("IPFS gateway", () => { () => new IpfsGateway({ template: "ftp://example.com/{cid}" }), ); }); + it("resolves subdomain and path gateways with URI encoding exactly once", async () => { + const cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + for (const template of [ + "https://{cid}.ipfs.dweb.link", + "https://gateway.example/ipfs/{cid}?download=true", + ]) { + const base = template.replace("{cid}", cid).split("?")[0]; + const query = template.includes("?") ? "?download=true" : ""; + const gateway = new IpfsGateway({ + template, + fetch: async (url) => { + assert.equal(String(url), base + "/lab.json" + query); + return new Response('{"ok":true}'); + }, + }); + assert.deepEqual(await gateway.readJson(`ipfs://${cid}/lab.json`), { + ok: true, + }); + for (const [input, expected] of [ + ["my%20folder", "my%20folder"], + ["my folder", "my%20folder"], + ["%E2%82%AC", "%E2%82%AC"], + ["€", "%E2%82%AC"], + ["%25", "%25"], + ["a%2fb", "a%2Fb"], + ["%5C", "%5C"], + ["%252e%252e", "%252e%252e"], + ]) + assert.equal( + gateway.resolve(`ipfs://${cid}/${input}/lab.json`).href, + base + "/" + expected + "/lab.json" + query, + ); + assert.equal( + gateway.resolve(`${cid}/my%20folder/lab.json`).href, + base + "/my%2520folder/lab.json" + query, + ); + for (const segment of [".", "..", "%2e", "%2E%2e", ".%2e", "%", "%FF"]) + assert.throws( + () => gateway.resolve(`ipfs://${cid}/${segment}/lab.json`), + /invalid IPFS/, + ); + } + }); it("bounds streamed bodies and cancels oversized responses", async () => { let cancelled = false; const gateway = new IpfsGateway({ diff --git a/src.ts/_tests/test-crypto.ts b/src.ts/_tests/test-crypto.ts index b1e9951a..0f9cd427 100644 --- a/src.ts/_tests/test-crypto.ts +++ b/src.ts/_tests/test-crypto.ts @@ -32,6 +32,10 @@ describe("updated crypto dependencies", () => { ), ); }); + it("enforces the backend memory budget without allocating oversized buffers", async () => { + assert.throws(() => scryptSync("0x", "0x", 262144, 8, 1, 32), /maxmem/); + await assert.rejects(scrypt("0x", "0x", 262144, 8, 1, 32), /maxmem/); + }); it("copies byte private keys and validates lengths", () => { const bytes = new Uint8Array(57).fill(1); const key = new SigningKey(bytes); diff --git a/src.ts/_tests/test-keystore.ts b/src.ts/_tests/test-keystore.ts index d184c800..76bcd010 100644 --- a/src.ts/_tests/test-keystore.ts +++ b/src.ts/_tests/test-keystore.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { Wallet, + scrypt, + scryptSync, networkIdToPrefix, encryptKeystoreJsonSync, encryptKeystoreJson, @@ -101,6 +103,65 @@ describe("AES keystores", () => { ); } }); + it("bounds combined scrypt work and all temporary buffers before either backend", async () => { + const data = JSON.parse(encryptKeystoreJsonSync(account, "test", options)); + let calls = 0; + const intercepted = new Error("backend reached"); + scryptSync.register(() => { + calls++; + throw intercepted; + }); + scrypt.register(async () => { + calls++; + throw intercepted; + }); + try { + for (const params of [ + { N: 1048576, r: 2, p: 524288 }, // Reported combination. + { N: 131072, r: 8, p: 3 }, // CPU only. + { N: 262144, r: 8, p: 1 }, // Scratch exceeds 256 MiB. + { N: 2, r: 524288, p: 2 }, // Parallel buffers exceed 256 MiB. + ]) { + Object.assign(data.Crypto.kdfparams, { + n: params.N, + r: params.r, + p: params.p, + }); + assert.throws( + () => decryptKeystoreJsonSync(JSON.stringify(data), "test"), + /unsafe/, + ); + await assert.rejects( + decryptKeystoreJson(JSON.stringify(data), "test"), + /unsafe/, + ); + assert.throws( + () => encryptKeystoreJsonSync(account, "test", { scrypt: params }), + /unsafe/, + ); + await assert.rejects( + encryptKeystoreJson(account, "test", { scrypt: params }), + /unsafe/, + ); + } + assert.equal(calls, 0); + for (const p of [1, 2]) { + Object.assign(data.Crypto.kdfparams, { n: 131072, r: 8, p }); + assert.throws( + () => decryptKeystoreJsonSync(JSON.stringify(data), "test"), + (error) => error === intercepted, + ); + await assert.rejects( + decryptKeystoreJson(JSON.stringify(data), "test"), + (error) => error === intercepted, + ); + } + assert.equal(calls, 4); + } finally { + scryptSync.register(scryptSync._); + scrypt.register(scrypt._); + } + }); it("rejects unsafe KDF parameters before allocating work", () => { for (const scrypt of [ { N: 0 }, diff --git a/src.ts/_version.ts b/src.ts/_version.ts index efcec189..5013add2 100644 --- a/src.ts/_version.ts +++ b/src.ts/_version.ts @@ -1,2 +1,2 @@ /** The current version of corebc; keep in sync with package.json. */ -export const version: string = "1.1.0"; +export const version: string = "1.1.1"; diff --git a/src.ts/crypto/scrypt.ts b/src.ts/crypto/scrypt.ts index e6c85dd8..d6ebf369 100644 --- a/src.ts/crypto/scrypt.ts +++ b/src.ts/crypto/scrypt.ts @@ -17,6 +17,9 @@ import type { BytesLike } from "../utils/index.js"; */ export type ProgressCallback = (percent: number) => void; +// Shared with keystore validation; includes noble's parallel and scratch buffers. +export const scryptMaxmem = 256 * 1024 * 1024; + let lockedSync = false, lockedAsync = false; @@ -29,7 +32,14 @@ const _scryptAsync = async function ( dkLen: number, onProgress?: ProgressCallback, ) { - return await _nobleAsync(passwd, salt, { N, r, p, dkLen, onProgress }); + return await _nobleAsync(passwd, salt, { + N, + r, + p, + dkLen, + onProgress, + maxmem: scryptMaxmem, + }); }; const _scryptSync = function ( passwd: Uint8Array, @@ -39,7 +49,7 @@ const _scryptSync = function ( p: number, dkLen: number, ) { - return _nobleSync(passwd, salt, { N, r, p, dkLen }); + return _nobleSync(passwd, salt, { N, r, p, dkLen, maxmem: scryptMaxmem }); }; let __scryptAsync: ( diff --git a/src.ts/ipfs/index.ts b/src.ts/ipfs/index.ts index 9cab0b7f..7ea7f556 100644 --- a/src.ts/ipfs/index.ts +++ b/src.ts/ipfs/index.ts @@ -31,18 +31,28 @@ export class IpfsGateway { resolve(reference: string): URL { const value = reference.trim(); if (/^https?:\/\//i.test(value)) return new URL(value); - const path = value.replace(/^ipfs:\/\//, "").replace(/^\/?ipfs\//, ""); - if ( - !/^[A-Za-z0-9]+(?:\/[^?#]*)?$/.test(path) || - path.split("/").some((part) => part === "." || part === "..") - ) + const isUri = /^ipfs:\/\//i.test(value); + const path = value.replace(/^ipfs:\/\//i, "").replace(/^\/?ipfs\//, ""); + if (!/^[A-Za-z0-9]+(?:\/[^?#]*)?$/.test(path)) throw new TypeError("invalid IPFS reference"); - return new URL( - this.template.replace( - "{cid}", - path.split("/").map(encodeURIComponent).join("/"), - ), - ); + const [cid, ...segments] = path.split("/"); + const encoded = segments.map((segment) => { + // URI segments are decoded independently so escaped separators stay + // within their original segment. Bare paths contain literal text. + let decoded: string; + try { + decoded = isUri ? decodeURIComponent(segment) : segment; + } catch { + throw new TypeError("invalid IPFS reference"); + } + if (decoded === "." || decoded === "..") + throw new TypeError("invalid IPFS reference"); + return encodeURIComponent(decoded); + }); + const url = new URL(this.template.replace("{cid}", cid)); + if (encoded.length) + url.pathname = url.pathname.replace(/\/$/, "") + "/" + encoded.join("/"); + return url; } async readJson(reference: string): Promise { const controller = new AbortController(); diff --git a/src.ts/wallet/json-keystore.ts b/src.ts/wallet/json-keystore.ts index e0eebb03..11bd8f12 100644 --- a/src.ts/wallet/json-keystore.ts +++ b/src.ts/wallet/json-keystore.ts @@ -16,6 +16,7 @@ const { } = pkg; import { getAddress } from "../address/index.js"; +import { scryptMaxmem } from "../crypto/scrypt.js"; import { pbkdf2Sync } from "../crypto/crypto.js"; import { pbkdf2, @@ -212,7 +213,8 @@ function getPbkdf2Key( return pbkdf2(password, salt, count, dkLen, algorithm); } -// Match Core Web3Dart's wallet KDF resource limits. +// Allow at most twice the default wallet work (131072 * 8 * 1). +// Include all noble temporary buffers in the memory budget. function validateScrypt(N: number, r: number, p: number): void { assertArgument( Number.isSafeInteger(N) && N > 1 && N <= 1048576 && (N & (N - 1)) === 0, @@ -226,7 +228,8 @@ function validateScrypt(N: number, r: number, p: number): void { r > 0 && p > 0 && r * p <= 1048576 && - 128 * N * r <= 256 * 1024 * 1024, + N * r * p <= 2 ** 21 && + 128 * r * (N + p + 1) <= scryptMaxmem, "unsafe scrypt work or memory requirement", "kdf", { N, r, p },