From 5fad9c2c58b94d0644793a136d7187147c9e478f Mon Sep 17 00:00:00 2001 From: Dmitry Redkin Date: Mon, 7 Sep 2026 16:54:03 +0300 Subject: [PATCH] fix: align shared secrets and PBKDF2 keystores with go-core --- CHANGELOG.md | 6 ++ README.md | 10 ++ scripts/test-browser.mjs | 31 ++++++ src.ts/_tests/crypto-compat-vectors.ts | 131 +++++++++++++++++++++++++ src.ts/_tests/test-crypto.ts | 24 ++++- src.ts/_tests/test-keystore.ts | 67 +++++++++++++ src.ts/crypto/crypto-browser.ts | 4 +- src.ts/crypto/ed448-types.ts | 1 + src.ts/crypto/signing-key.ts | 10 +- src.ts/wallet/json-keystore.ts | 34 +++++-- 10 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 src.ts/_tests/crypto-compat-vectors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbd8826..c4bfc50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- 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. + ## 1.1.0 - Add the German community mnemonic wordlist from dys2p/wordlists-de as `wordlists.de` and `LangDe` in the extra browser bundle. diff --git a/README.md b/README.md index 732f9e42..43685c86 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ 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. +`SigningKey.computeSharedSecret` returns the 56-byte X448 shared secret used +by go-core, accepting the peer's 57-byte Ed448 public key. This changes the +57-byte Edwards-point result returned by version 1.1.0. Applications using +the previous result as key material must account for that change. + +PBKDF2 keystore imports recognize go-core's SHA3-256 interpretation of +`hmac-sha256`, with MAC-checked fallback for historical CoreBC SHA2-256 +keystores. SHA2-512 keystores remain supported. This does not change the +public `pbkdf2` API, mnemonic derivation, or scrypt keystore exports. + `Contract` accepts JSON or human-readable ABIs and a provider for reads or signer for writes. Transaction fields use `energyLimit`, `energyPrice`, and `networkId`. diff --git a/scripts/test-browser.mjs b/scripts/test-browser.mjs index a0cc1584..4ad6b241 100644 --- a/scripts/test-browser.mjs +++ b/scripts/test-browser.mjs @@ -1,6 +1,10 @@ import { readFile } from "node:fs/promises"; import vm from "node:vm"; import { webcrypto } from "node:crypto"; +import { + sharedSecretVectors, + pbkdf2KeystoreVectors, +} from "../lib.esm/_tests/crypto-compat-vectors.js"; for (const file of ["dist/corebc.umd.js", "dist/corebc.umd.min.js"]) { const context = vm.createContext({ crypto: webcrypto, @@ -12,6 +16,8 @@ for (const file of ["dist/corebc.umd.js", "dist/corebc.umd.min.js"]) { AbortController, fetch, URL, + sharedSecretVectors, + pbkdf2KeystoreVectors, }); vm.runInContext( "globalThis.self = globalThis; globalThis.window = globalThis;", @@ -33,6 +39,31 @@ for (const file of ["dist/corebc.umd.js", "dist/corebc.umd.min.js"]) { `, context, ); + await vm.runInContext( + `(async () => { + for (const vector of sharedSecretVectors) { + const a = new corebc.SigningKey(vector.privateA); + const b = new corebc.SigningKey(vector.privateB); + if (a.computeSharedSecret(b.publicKey) !== vector.secret || b.computeSharedSecret(a.publicKey) !== vector.secret) throw new Error("browser X448 vector mismatch: " + vector.name); + for (const peer of ["0x" + "ff".repeat(57), "0x" + "00".repeat(57), "0x01" + "00".repeat(56)]) { + let rejected = false; + try { a.computeSharedSecret(peer); } catch { rejected = true; } + if (!rejected) throw new Error("browser accepted an invalid X448 peer"); + } + } + for (const vector of pbkdf2KeystoreVectors) { + const json = JSON.stringify(vector.keystore); + if (corebc.decryptKeystoreJsonSync(json, vector.password).privateKey !== vector.privateKey) throw new Error("browser PBKDF2 sync mismatch: " + vector.name); + if ((await corebc.decryptKeystoreJson(json, vector.password)).privateKey !== vector.privateKey) throw new Error("browser PBKDF2 async mismatch: " + vector.name); + for (const decrypt of [corebc.decryptKeystoreJsonSync, corebc.decryptKeystoreJson]) { + let rejected = false; + try { await decrypt(json, "wrong"); } catch (error) { rejected = error.code === "INVALID_ARGUMENT" && error.argument === "password"; } + if (!rejected) throw new Error("browser accepted an incorrect PBKDF2 password"); + } + } + })()`, + context, + ); } for (const file of ["wordlists-extra.js", "wordlists-extra.min.js"]) { const { LangSk, LangDe } = await import( diff --git a/src.ts/_tests/crypto-compat-vectors.ts b/src.ts/_tests/crypto-compat-vectors.ts new file mode 100644 index 00000000..2ab769b3 --- /dev/null +++ b/src.ts/_tests/crypto-compat-vectors.ts @@ -0,0 +1,131 @@ +// Disposable compatibility vectors; no production keys. +// X448 expected outputs were calculated with Python cryptography/OpenSSL; +// the seed vector also matches go-goldilocks TestAdditionalX448Vectors. +// Public-key conversion and seed/scalar selection follow core-coin/ed448 v1.0.2. +// Keystores were generated independently with Node crypto PBKDF2, AES-128-CTR +// and SHA3-256 MACs. The sha3-256 case follows go-core a091fe2d3 getKDFKey; +// SHA2 cases preserve the historical CoreBC import formats. +export const sharedSecretVectors = [ + { + name: "seed", + privateA: + "0x030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303", + publicA: + "0x18e2f925aef56fc05910474593ee84c932bc7ea4d352a3416f72d115e60eb58cd862f73ac50886fbaad6354a4eeb01fd9f740491fb11f59580", + privateB: + "0x040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404040404", + publicB: + "0x4e15975a78604b2d6575f2d3310ca4e4f122226189f5900f744c1ead49408bdf8247843c05ba24fbb26c9b5f81c287cbbe3d3e8a3ad0231d00", + secret: + "0xb5353e76df0e00c528f800f4659a7eab368f29374870979245eed3f2e9bda1351d70ca219ade1eb0b76fcea78af42268d46723ffdd9e36fb", + }, + { + name: "mixed", + privateA: + "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + publicA: + "0xe0758a33267939a394fb5ccb202ee851cebc2e89c91ac1289e2bfcddfd9ff9fc5694b0f569d7f7e9da16e1cde9301b29f48128b3cbd1168580", + privateB: + "0x020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020280", + publicB: + "0x7feb9357e8aceb83b713ad38f2e43e92050a13e317d58ff08195ff46247bc5dd3120fcc5346667a0bbdb54a66e96aed90b755b47793e849000", + secret: + "0xe34645238d104c7ae8fc8e54cbdcb937270f96648e1f60faee6c9d9121b54ae1179ba8158dea842cbc8ae31557c96b22f1fe8d7cc2cf9375", + }, + { + name: "scalar", + privateA: + "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010180", + publicA: + "0x9115fe1455b552b73a73ae0172dc39bb9deec3f91e0f4a9d5bc418ff0217dc50ddc8b982756a35b13597a48459e6b6f9eb24c4bdb05f6d6780", + privateB: + "0x0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202ff", + publicB: + "0x7feb9357e8aceb83b713ad38f2e43e92050a13e317d58ff08195ff46247bc5dd3120fcc5346667a0bbdb54a66e96aed90b755b47793e849000", + secret: + "0xfabbfd711b429df0a179a9dc29a1d7e1284452d471caf6c80b9b151d60b4de30a560885926e884cb1056aac6ef9977c5e0394913d25cdd74", + }, +]; +export const pbkdf2KeystoreVectors = [ + { + name: "sha3-256", + password: "compatibility-test", + privateKey: + "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + address: "0xab41f5e89ba978317da861b7ad06ba3103d41c876098", + keystore: { + version: 3, + address: "ab41f5e89ba978317da861b7ad06ba3103d41c876098", + Crypto: { + cipher: "aes-128-ctr", + cipherparams: { + iv: "03030303030303030303030303030303", + }, + ciphertext: + "6662d0dd06a9fce21fb06ad937e0d424794a9ee7638e978ab9015dbbf62ab24ba2f74ce6ddb9b6c0869c61f1f01b0521eee1486d4226adddd5", + mac: "879a98aa1cf740c375ce07358f619fc4dc9d4a8dbed0cf33efd44165ab91ad8d", + kdf: "pbkdf2", + kdfparams: { + c: 2048, + dklen: 32, + prf: "hmac-sha256", + salt: "0202020202020202020202020202020202020202020202020202020202020202", + }, + }, + }, + }, + { + name: "sha256", + password: "compatibility-test", + privateKey: + "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + address: "0xab41f5e89ba978317da861b7ad06ba3103d41c876098", + keystore: { + version: 3, + address: "ab41f5e89ba978317da861b7ad06ba3103d41c876098", + Crypto: { + cipher: "aes-128-ctr", + cipherparams: { + iv: "03030303030303030303030303030303", + }, + ciphertext: + "90adee35ec0cdd076d2e9078b7225af267fc7d075a77b9c079db6d83ac2d797347d8c5c9ba769a6897859cf0f4cb4d5c222d7573c97593d1a7", + mac: "521eabcd4b9407533c23ffe1f83737e8a87f503fe27fe2ca528cda6929c28dbf", + kdf: "pbkdf2", + kdfparams: { + c: 2048, + dklen: 32, + prf: "hmac-sha256", + salt: "0202020202020202020202020202020202020202020202020202020202020202", + }, + }, + }, + }, + { + name: "sha512", + password: "compatibility-test", + privateKey: + "0x010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101", + address: "0xab41f5e89ba978317da861b7ad06ba3103d41c876098", + keystore: { + version: 3, + address: "ab41f5e89ba978317da861b7ad06ba3103d41c876098", + Crypto: { + cipher: "aes-128-ctr", + cipherparams: { + iv: "03030303030303030303030303030303", + }, + ciphertext: + "5f41211590deff955e6f4bbbd7d339e67234ec9e21ea1a69a4bf359029838e5eedd43746b7fffe11bbb04b1dd8e0f89fd4ca87c45b49c45dfc", + mac: "1e61cbe0b9f1b663fa256193e45327479a4db1a8048ae974b35ade0ad7c95b8c", + kdf: "pbkdf2", + kdfparams: { + c: 2048, + dklen: 32, + prf: "hmac-sha512", + salt: "0202020202020202020202020202020202020202020202020202020202020202", + }, + }, + }, + }, +]; diff --git a/src.ts/_tests/test-crypto.ts b/src.ts/_tests/test-crypto.ts index b66ebf3b..b1e9951a 100644 --- a/src.ts/_tests/test-crypto.ts +++ b/src.ts/_tests/test-crypto.ts @@ -9,6 +9,7 @@ import { keccak256, } from "../index.js"; import { createHash } from "../crypto/crypto-browser.js"; +import { sharedSecretVectors } from "./crypto-compat-vectors.js"; describe("updated crypto dependencies", () => { it("matches standard hash and scrypt vectors", async () => { @@ -40,7 +41,7 @@ describe("updated crypto dependencies", () => { assert.throws(() => new SigningKey(new Uint8Array(length))); assert.throws(() => key.sign("0x00")); }); - it("signs, rejects tampering and derives symmetric Ed448 secrets in both key modes", () => { + it("signs, rejects tampering and derives symmetric secrets in both key modes", () => { const a = new SigningKey("01".repeat(57)); const b = new SigningKey("02".repeat(56) + "80"); const digest = sha256("0x1234"); @@ -59,4 +60,25 @@ describe("updated crypto dependencies", () => { ); assert.throws(() => a.computeSharedSecret("0x00")); }); + for (const vector of sharedSecretVectors) { + it(`matches Core X448 shared secrets in ${vector.name} mode`, () => { + const a = new SigningKey(vector.privateA); + const b = new SigningKey(vector.privateB); + assert.equal(a.publicKey, vector.publicA); + assert.equal(b.publicKey, vector.publicB); + assert.equal(a.computeSharedSecret(b.publicKey), vector.secret); + assert.equal(b.computeSharedSecret(a.publicKey), vector.secret); + assert.equal(getBytes(vector.secret).length, 56); + assert.equal(a.privateKey, vector.privateA); + }); + } + it("rejects invalid and small-order peers during shared-secret derivation", () => { + const key = new SigningKey(sharedSecretVectors[0].privateA); + for (const peer of [ + "0x" + "ff".repeat(57), + "0x" + "00".repeat(57), + "0x01" + "00".repeat(56), + ]) + assert.throws(() => key.computeSharedSecret(peer)); + }); }); diff --git a/src.ts/_tests/test-keystore.ts b/src.ts/_tests/test-keystore.ts index dccfe38f..d184c800 100644 --- a/src.ts/_tests/test-keystore.ts +++ b/src.ts/_tests/test-keystore.ts @@ -7,6 +7,73 @@ import { decryptKeystoreJsonSync, decryptKeystoreJson, } from "../index.js"; +import { pbkdf2KeystoreVectors } from "./crypto-compat-vectors.js"; + +describe("PBKDF2 keystore interoperability", () => { + for (const vector of pbkdf2KeystoreVectors) { + it(`imports ${vector.name} wallets with both APIs`, async () => { + const json = JSON.stringify(vector.keystore); + const expected = { + address: vector.address, + privateKey: vector.privateKey, + }; + assert.deepEqual( + decryptKeystoreJsonSync(json, vector.password), + expected, + ); + const progress: number[] = []; + assert.deepEqual( + await decryptKeystoreJson(json, vector.password, (p) => + progress.push(p), + ), + expected, + ); + assert.deepEqual(progress, [0, 1]); + assert.equal( + Wallet.fromEncryptedJsonSync(json, vector.password).address, + vector.address, + ); + }); + it(`rejects wrong passwords and tampering for ${vector.name}`, async () => { + const json = JSON.stringify(vector.keystore); + assert.throws( + () => decryptKeystoreJsonSync(json, "wrong"), + /incorrect password/, + ); + await assert.rejects( + decryptKeystoreJson(json, "wrong"), + /incorrect password/, + ); + for (const field of ["mac", "ciphertext"] as const) { + const changed = structuredClone(vector.keystore); + const bytes = changed.Crypto[field]; + changed.Crypto[field] = + (bytes.startsWith("00") ? "01" : "00") + bytes.slice(2); + const modified = JSON.stringify(changed); + assert.throws( + () => decryptKeystoreJsonSync(modified, vector.password), + /incorrect password/, + ); + await assert.rejects( + decryptKeystoreJson(modified, vector.password), + /incorrect password/, + ); + } + }); + } + it("retains address validation after authenticating a Core keystore", () => { + const vector = pbkdf2KeystoreVectors[0]; + const changed = structuredClone(vector.keystore); + changed.address = Wallet.fromSeed({ + seed: "02".repeat(64), + prefix: networkIdToPrefix(3), + }).address.slice(2); + assert.throws( + () => decryptKeystoreJsonSync(JSON.stringify(changed), vector.password), + /address mismatch/, + ); + }); +}); describe("AES keystores", () => { const wallet = Wallet.fromSeed({ diff --git a/src.ts/crypto/crypto-browser.ts b/src.ts/crypto/crypto-browser.ts index 06c90c20..21b1d433 100644 --- a/src.ts/crypto/crypto-browser.ts +++ b/src.ts/crypto/crypto-browser.ts @@ -71,9 +71,9 @@ export function pbkdf2Sync( salt: Uint8Array, iterations: number, keylen: number, - _algo: "sha256" | "sha512", + _algo: "sha256" | "sha512" | "sha3-256", ): Uint8Array { - const algo = { sha256, sha512 }[_algo]; + const algo = { sha256, sha512, "sha3-256": sha3_256 }[_algo]; assertArgument(algo != null, "invalid pbkdf2 algorithm", "algorithm", _algo); return pbkdf2(algo, password, salt, { c: iterations, dkLen: keylen }); } diff --git a/src.ts/crypto/ed448-types.ts b/src.ts/crypto/ed448-types.ts index a73d9510..8fdc1517 100644 --- a/src.ts/crypto/ed448-types.ts +++ b/src.ts/crypto/ed448-types.ts @@ -5,6 +5,7 @@ export interface Ed448Backend { publicKeyCreate(key: Buffer): Buffer; publicKeyFromScalar(scalar: Buffer): Buffer; publicKeyVerify(key: Buffer): boolean; + publicKeyConvert(key: Buffer): Buffer; publicKeyCombine(keys: Buffer[]): Buffer; sign(message: Buffer, key: Buffer): Buffer; signWithScalar(message: Buffer, scalar: Buffer, prefix: Buffer): Buffer; diff --git a/src.ts/crypto/signing-key.ts b/src.ts/crypto/signing-key.ts index 8e3bbf35..7fb29f10 100644 --- a/src.ts/crypto/signing-key.ts +++ b/src.ts/crypto/signing-key.ts @@ -100,7 +100,7 @@ export class SigningKey { return hexConcat([hexlified, this.publicKey]); } - /** Derives an Ed448 shared secret from a 57-byte public key. Hash it before use as a symmetric key. */ + /** Derives the 56-byte X448 shared secret used by go-core from a 57-byte Ed448 public key. Hash it before use as a symmetric key. */ computeSharedSecret(other: BytesLike): string { const pub = Buffer.from(getBytesCopy(other)); assertArgument( @@ -114,9 +114,13 @@ export class SigningKey { const scalar = key.subarray(0, 56); scalar[0] &= 0xfc; scalar[55] |= 0x80; - return hexlify(ed448.deriveWithScalar(pub, scalar)); + return hexlify( + ed448.publicKeyConvert(ed448.deriveWithScalar(pub, scalar)), + ); } - return hexlify(ed448.derive(pub, key)); + // The Edwards-to-Montgomery map commutes with scalar multiplication. + // Encode the shared point as X448, matching go-core's Ed448DeriveSecret. + return hexlify(ed448.publicKeyConvert(ed448.derive(pub, key))); } /** Computes an Ed448 public key from a 57-byte private key. The compression flag is ignored. */ diff --git a/src.ts/wallet/json-keystore.ts b/src.ts/wallet/json-keystore.ts index 26e3b300..e0eebb03 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 { pbkdf2Sync } from "../crypto/crypto.js"; import { pbkdf2, randomBytes, @@ -99,8 +100,7 @@ function decrypt(data: any, key: Uint8Array, ciphertext: Uint8Array): string { }); } -function getAccount(data: any, _key: string): KeystoreAccount { - const key = getBytes(_key); +function hasValidMac(data: any, key: Uint8Array): boolean { const ciphertext = spelunk(data, "crypto.ciphertext:data!"); const computedMAC = hexlify( @@ -115,8 +115,14 @@ function getAccount(data: any, _key: string): KeystoreAccount { for (let i = 0; i < actual.length; i++) difference |= actual[i] ^ expected[i]; } + return validMAC && difference === 0; +} + +function getAccount(data: any, _key: string): KeystoreAccount { + const key = getBytes(_key); + const ciphertext = spelunk(data, "crypto.ciphertext:data!"); assertArgument( - validMAC && difference === 0, + hasValidMac(data, key), "incorrect password", "password", "[REDACTED]", @@ -190,6 +196,22 @@ type KdfParams = algorithm: "sha256" | "sha512"; }; +function getPbkdf2Key( + data: any, + password: Uint8Array, + params: Extract, +): string { + const { salt, count, dkLen, algorithm } = params; + if (algorithm === "sha256") { + // go-core labels HMAC-SHA3-256 as "hmac-sha256" in keystores. + const key = pbkdf2Sync(password, salt, count, dkLen, "sha3-256"); + if (hasValidMac(data, key)) return hexlify(key); + } + // Historical CoreBC keystores used SHA2. Authenticate that candidate in + // getAccount; do not fall back after cipher or address-validation errors. + return pbkdf2(password, salt, count, dkLen, algorithm); +} + // Match Core Web3Dart's wallet KDF resource limits. function validateScrypt(N: number, r: number, p: number): void { assertArgument( @@ -284,8 +306,7 @@ export function decryptKeystoreJsonSync( const params = getDecryptKdfParams(data); if (params.name === "pbkdf2") { - const { salt, count, dkLen, algorithm } = params; - const key = pbkdf2(password, salt, count, dkLen, algorithm); + const key = getPbkdf2Key(data, password, params); return getAccount(data, key); } @@ -332,8 +353,7 @@ export async function decryptKeystoreJson( progress(0); await stall(0); } - const { salt, count, dkLen, algorithm } = params; - const key = pbkdf2(password, salt, count, dkLen, algorithm); + const key = getPbkdf2Key(data, password, params); if (progress) { progress(1); await stall(0);