Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
44 changes: 44 additions & 0 deletions src.ts/_tests/test-cip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions src.ts/_tests/test-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions src.ts/_tests/test-keystore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import {
Wallet,
scrypt,
scryptSync,
networkIdToPrefix,
encryptKeystoreJsonSync,
encryptKeystoreJson,
Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion src.ts/_version.ts
Original file line number Diff line number Diff line change
@@ -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";
14 changes: 12 additions & 2 deletions src.ts/crypto/scrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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: (
Expand Down
32 changes: 21 additions & 11 deletions src.ts/ipfs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
const controller = new AbortController();
Expand Down
7 changes: 5 additions & 2 deletions src.ts/wallet/json-keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 },
Expand Down