Repository navigation
fix!: use the standard libsecp256k1 ECDH key for DashPay encryption - #303
HashEngineering wants to merge 1 commit into
Conversation
Secp256k1ECDHAgreement returned SHA256((y&1|2) || x) as a signed BigInteger, and since #295 (7a9db02) KeyCrypterECDH.deriveKey turned it back into bytes with BigIntegers.asUnsignedByteArray(32, ...). When the hash starts with 0xFF followed by a byte >= 0x80 (about 1 in 512 key pairs) that drops the leading 0xFF(s) and pads with 0x00 (ff80.. -> 0080.., ffff80.. -> 000080..), so dashj used a different AES key than libsecp256k1, iOS and the Rust SDK. dashj could talk to dashj, but not to the other implementations, in either direction. - Secp256k1ECDHAgreement.calculateSharedSecret returns the 32 hash bytes; calculateAgreement keeps its old signed result and is deprecated. - KeyCrypterECDH.deriveKey uses the 32 bytes as-is (the standard key). This is the only key used for encryption. - deriveLegacyKey / deriveCandidateKeys / decryptWithFallback let callers read data written with the #295 key: the standard key is tried first, the legacy key only when it differs and the standard key fails. - Tests: a deterministic FF-then->=0x80 pair (found with an independent Python secp256k1), legacy and standard round trips, and FF-then-<0x80, 00-then-<0x80 and 00-then->=0x80 pairs where both keys agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
Issue being fixed or feature implemented
For about 1 DashPay contact pair in 512, dashj encrypts and decrypts contact requests with a non-standard ECDH key. dashj-to-dashj still works, but libsecp256k1, iOS (DashSync) and the Rust Platform SDK can't decrypt those requests, and the SDK can't build the contact's sending account.
Cause:
Secp256k1ECDHAgreement.calculateAgreementcomputes the standard libsecp256k1 value, SHA256((y&1|2) ‖ x), but returns it asnew BigInteger(Sha256Hash.hash(...)), which reads the hash as a signed number. Since #295,KeyCrypterECDH.deriveKeyturns it back into bytes withBigIntegers.asUnsignedByteArray(32, ...).0xFFfollowed by a byte ≥0x80, the minimal two's-complement encoding drops the leading0xFF, and the padding puts0x00in its place:ff80…→0080…,ffff80…→000080….00ones) got a 31-byte key and failed outright. fix: ecdh shared key width #295 fixed the leading-00case but turned the leading-FFcase into a silently different key.Confirmed on mainnet. Two wallets whose requests are both dated after #295 were affected in both directions: dashj decrypted them fine, the Rust SDK did not. An SDK build that retries with dashj's altered key decrypted the request on the first try ("Contact xpub decrypted with the dashj-legacy ECDH key") and built the account. The SDK side of that fix is in HashEngineering/platform
fix/dashj-legacy-ecdh-key.What was done?
Secp256k1ECDHAgreement:byte[] calculateSharedSecret(CipherParameters)returns the 32 SHA256 bytes directly.calculateAgreementkeeps its oldBigIntegerresult forBasicAgreementcompatibility. It is now@Deprecated, and its javadoc explains the problem.KeyCrypterECDH.deriveKeynow uses the 32 bytes directly as the AES key. This is the standard key, the same as libsecp256k1, iOS and the SDK, and it is used for all encryption.deriveLegacyKey(ECKey, ECKey): the fix: ecdh shared key width #295 key, rebuilt exactly asasUnsignedByteArray(32, new BigInteger(hash)).deriveCandidateKeys(ECKey, ECKey): the standard key first, plus the legacy key only when it differs (about 1 pair in 512).decryptWithFallback(EncryptedData, ECKey, ECKey)/decryptWithFallback(EncryptedData, List<KeyParameter>): try the standard key, then the legacy key. If both fail, the standard key's error is thrown.deriveCandidateKeysand keep the first result that parses. For example, the decrypted xpub should deserialize.Nothing else in dashj uses these classes. The consumer is dash-sdk-kotlin (
BlockchainIdentity.decryptExtendedPublicKey, plus the account-label and contactInfo decrypts). It needs a follow-up to decrypt withderiveCandidateKeys; encryption needs no change there.How Has This Been Tested?
./gradlew :dashj-core:test --tests 'org.bitcoinj.crypto.*': 147 passed, 3 skipped. InKeyCrypterECDHTest:7337920e…aa01, Bob is SHA256("dashj-ecdh-test-498") =4a42b025…6bce.ffc083b9…7e74, equal to the raw SHA256, checked against an independent hash of the compressed shared point. I also verified it with a separate Python secp256k1.00c083b9….ff+<80,00+<80,00+≥80, the original pair and the known-answer pair. The legacy key equals the standard one, there is a single candidate, and no second attempt is made.ff80→0080,ffff80→000080,ffff7f→00ff7f.ff7f,fe80,8000,0080,007f,0000,7fffand all-zero stay unchanged.Breaking Changes
KeyCrypterECDH.deriveKeyreturns a different key for the affected ~1/512 pairs: the standard key instead of #295's. So for those pairs, data encrypted by this dashj can't be decrypted by an older dashj, which only knows the legacy key. iOS and the Rust SDK can now read it, in both directions. Data encrypted by an older dashj stays readable throughderiveCandidateKeys/decryptWithFallback. For every other pair, the key is unchanged.Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code
Summary by CodeRabbit