Skip to content

Commit 9bf31e7

Browse files
bfoss765claude
andcommitted
fix(kotlin-sdk): make identity-key repair ownership check HASH160-aware (dashpay#4183)
The repair path derived the KEYPAIR and required its public half to equal the key's stored on-chain data before persisting. For ECDSA_HASH160 / EDDSA_25519_HASH160 keys DPP stores the 20-byte HASH160 of the pubkey as that data, not the pubkey, so the raw 33-vs-20-byte contentEquals could never match and those key types were permanently un-repairable. derivedPublicKeyMatches now takes the DPP key-type discriminant and HASH160s the derived pubkey (RIPEMD160(SHA256)) before comparing for HASH160 types; every other type keeps the plain content comparison. keyType is threaded through PrivateKeyDeriver.deriveAndStore; the repair path reads it from the persisted row's breadcrumbs, the store path passes it from the persist callback. Adds a pure-Kotlin Hash160 helper (public bytes only — no derivation/secrets, within the CLAUDE.md doctrine) pinned to RIPEMD-160 reference vectors, and tests proving a HASH160-type key repairs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 48c4726 commit 9bf31e7

6 files changed

Lines changed: 369 additions & 16 deletions

File tree

‎packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,7 @@ class PlatformWalletPersistenceHandler(
11121112
publicKeyData = publicKeyData,
11131113
identityIndex = identityIndex,
11141114
keyIndex = keyIndex,
1115+
keyType = keyType.toInt() and 0xFF,
11151116
)
11161117
}
11171118
val id = outcome.getOrNull()
@@ -2690,31 +2691,40 @@ class PlatformWalletPersistenceHandler(
26902691
"the key remains unusable and pending state is left intact",
26912692
)
26922693

2693-
// BLOCKER 1: read the derivation indices from the persisted row, never
2694-
// from the caller. A row lacking breadcrumbs cannot be safely repaired
2695-
// (we would have to guess the slot), so fail WITHOUT clearing pending.
2694+
// BLOCKER 1: read the derivation indices (and the DPP key type, so the
2695+
// deriver's ownership check interprets publicKeyData correctly for
2696+
// HASH160-typed keys — dashpay/platform#4183 review) from the persisted
2697+
// row, never from the caller. A row lacking breadcrumbs cannot be
2698+
// safely repaired (we would have to guess the slot), so fail WITHOUT
2699+
// clearing pending.
26962700
val breadcrumbs = database.publicKeyDao().getByPublicKeyData(publicKeyData)
26972701
.firstNotNullOfOrNull { row ->
26982702
val identityIndex = row.derivationIdentityIndex
26992703
val keyIndex = row.derivationKeyIndex
2700-
if (identityIndex != null && keyIndex != null) identityIndex to keyIndex else null
2704+
if (identityIndex != null && keyIndex != null) {
2705+
Triple(identityIndex, keyIndex, row.keyType.toIntOrNull() ?: 0)
2706+
} else {
2707+
null
2708+
}
27012709
} ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable(
27022710
"cannot repair identity key $pubkeyHex: no derivation breadcrumbs are " +
27032711
"persisted for it (derivationIdentityIndex/derivationKeyIndex are " +
27042712
"null) — the correct slot is unknown; pending state left intact",
27052713
)
2706-
val (identityIndex, keyIndex) = breadcrumbs
2714+
val (identityIndex, keyIndex, keyType) = breadcrumbs
27072715

27082716
// force = true routes through WalletStorage.replacePrivateKey and, in
27092717
// the production deriver, derives the KEYPAIR and verifies the derived
2710-
// public key equals publicKeyData BEFORE any store — a mismatch throws
2718+
// public key equals publicKeyData (HASH160-hashed first for HASH160 key
2719+
// types) BEFORE any store — a mismatch throws
27112720
// IdentityKeyDerivationMismatchException here, so nothing below runs
27122721
// and pending is never cleared (BLOCKER 1).
27132722
val storageIdentifier = deriver.deriveAndStore(
27142723
walletId = walletId,
27152724
publicKeyData = publicKeyData,
27162725
identityIndex = identityIndex,
27172726
keyIndex = keyIndex,
2727+
keyType = keyType,
27182728
force = true,
27192729
)?.identifier ?: return null
27202730

@@ -2928,8 +2938,17 @@ interface PrivateKeyDeriver {
29282938
* Returns `null` if the key could not be derived/stored (leaving it
29292939
* watch-only).
29302940
*
2931-
* @param publicKeyData the compressed public-key bytes — used as the
2941+
* @param publicKeyData the on-chain public-key data — the compressed
2942+
* pubkey, or the 20-byte HASH160 for a HASH160 key type — used as the
29322943
* storage key so the signer can locate the scalar.
2944+
* @param keyType the DPP `KeyType` discriminant of this key. Only the
2945+
* [force] repair path consults it: it tells the pubkey-ownership check
2946+
* whether [publicKeyData] is the raw derived pubkey or its HASH160, so a
2947+
* HASH160-type key (`ECDSA_HASH160` = 2, `EDDSA_25519_HASH160` = 4) is
2948+
* verified by hashing the derived pubkey rather than comparing raw bytes
2949+
* that can never match (dashpay/platform#4183 review). Defaults to
2950+
* `ECDSA_SECP256K1` (0) for the non-repair store path, which does no
2951+
* pubkey comparison.
29332952
* @param force when true, skip the "already usable" short-circuit and
29342953
* REPLACE the stored entry unconditionally — the repair path
29352954
* (dashpay/platform#4060 finding 6), where a shape+fingerprint-valid
@@ -2942,6 +2961,7 @@ interface PrivateKeyDeriver {
29422961
publicKeyData: ByteArray,
29432962
identityIndex: Int,
29442963
keyIndex: Int,
2964+
keyType: Int = 0,
29452965
force: Boolean = false,
29462966
): DerivedKeyStoreResult?
29472967

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package org.dashfoundation.dashsdk.security
2+
3+
import java.security.MessageDigest
4+
5+
/**
6+
* Bitcoin/Dash **HASH160** — `RIPEMD160(SHA256(x))`, 20 bytes.
7+
*
8+
* ## Why this exists in the SDK
9+
*
10+
* A DPP identity key whose type is `ECDSA_HASH160` / `EDDSA_25519_HASH160`
11+
* stores its on-chain "public key data" as the 20-byte HASH160 of the
12+
* underlying public key, NOT the public key itself (rs-dpp `KeyType`;
13+
* `IdentityPubkeyCodec` — "compressed pubkey, or 20-byte HASH160"). The
14+
* identity-key repair path
15+
* ([IdentityKeyPrivateKeyDeriver.derivedPublicKeyMatches]) must therefore
16+
* hash the derived public key before comparing it to the stored value, or a
17+
* HASH160-type key can NEVER be proven to match and repair fails forever
18+
* (dashpay/platform#4183 review). This is the ownership check the reviewer
19+
* asked for.
20+
*
21+
* ## Doctrine
22+
*
23+
* `packages/kotlin-sdk/CLAUDE.md` forbids **derivation**, **policy loops**,
24+
* and **re-implementing protocol constants** in Kotlin. HASH160 is none of
25+
* those: it hashes already-**public** bytes (no key material, no secret, no
26+
* derivation-path logic) with two fully standardized, parameter-free digests.
27+
* SHA-256 ships in the JDK; the JDK ships no RIPEMD-160 `MessageDigest`, so
28+
* the standard public-domain RIPEMD-160 (Dobbertin/Bosselaers/Preneel,
29+
* ISO/IEC 10118-3) is inlined below and pinned against reference vectors in
30+
* `Hash160Test`. iOS computes the same value via the Rust
31+
* `platform_wallet_hash160` FFI helper; swap this for a JNI-bridged call if
32+
* one is ever exported.
33+
*
34+
* Pure + side-effect free so the repair's before-persistence identity check
35+
* stays unit-testable without the native derive.
36+
*/
37+
internal object Hash160 {
38+
39+
/** HASH160 = RIPEMD160(SHA256([input])) — 20 bytes. */
40+
fun hash160(input: ByteArray): ByteArray =
41+
ripemd160(MessageDigest.getInstance("SHA-256").digest(input))
42+
43+
/** RIPEMD-160 of [input] — 20 bytes. */
44+
fun ripemd160(input: ByteArray): ByteArray {
45+
var h0 = 0x67452301
46+
var h1 = 0xEFCDAB89.toInt()
47+
var h2 = 0x98BADCFE.toInt()
48+
var h3 = 0x10325476
49+
var h4 = 0xC3D2E1F0.toInt()
50+
51+
// MD4-style padding: 0x80, zeros, then the 64-bit little-endian
52+
// bit length, to a multiple of 64 bytes.
53+
val bitLength = input.size.toLong() * 8
54+
val paddedLength = ((input.size + 8) / 64 + 1) * 64
55+
val padded = input.copyOf(paddedLength)
56+
padded[input.size] = 0x80.toByte()
57+
for (i in 0 until 8) {
58+
padded[paddedLength - 8 + i] = (bitLength ushr (8 * i)).toByte()
59+
}
60+
61+
val x = IntArray(16)
62+
var offset = 0
63+
while (offset < paddedLength) {
64+
for (i in 0 until 16) {
65+
val base = offset + 4 * i
66+
x[i] = (padded[base].toInt() and 0xFF) or
67+
((padded[base + 1].toInt() and 0xFF) shl 8) or
68+
((padded[base + 2].toInt() and 0xFF) shl 16) or
69+
((padded[base + 3].toInt() and 0xFF) shl 24)
70+
}
71+
72+
var a = h0; var b = h1; var c = h2; var d = h3; var e = h4
73+
var ap = h0; var bp = h1; var cp = h2; var dp = h3; var ep = h4
74+
75+
for (j in 0 until 80) {
76+
val round = j / 16
77+
var t = a + f(round, b, c, d) + x[R_LEFT[j]] + K_LEFT[round]
78+
t = Integer.rotateLeft(t, S_LEFT[j]) + e
79+
a = e; e = d; d = Integer.rotateLeft(c, 10); c = b; b = t
80+
81+
var tp = ap + f(4 - round, bp, cp, dp) + x[R_RIGHT[j]] + K_RIGHT[round]
82+
tp = Integer.rotateLeft(tp, S_RIGHT[j]) + ep
83+
ap = ep; ep = dp; dp = Integer.rotateLeft(cp, 10); cp = bp; bp = tp
84+
}
85+
86+
val t = h1 + c + dp
87+
h1 = h2 + d + ep
88+
h2 = h3 + e + ap
89+
h3 = h4 + a + bp
90+
h4 = h0 + b + cp
91+
h0 = t
92+
93+
offset += 64
94+
}
95+
96+
val out = ByteArray(20)
97+
intArrayOf(h0, h1, h2, h3, h4).forEachIndexed { i, word ->
98+
for (bIdx in 0 until 4) {
99+
out[4 * i + bIdx] = (word ushr (8 * bIdx)).toByte()
100+
}
101+
}
102+
return out
103+
}
104+
105+
/** The five round functions f1..f5, selected by round index 0..4. */
106+
private fun f(round: Int, x: Int, y: Int, z: Int): Int = when (round) {
107+
0 -> x xor y xor z
108+
1 -> (x and y) or (x.inv() and z)
109+
2 -> (x or y.inv()) xor z
110+
3 -> (x and z) or (y and z.inv())
111+
else -> x xor (y or z.inv())
112+
}
113+
114+
private val K_LEFT = intArrayOf(
115+
0x00000000, 0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC.toInt(), 0xA953FD4E.toInt(),
116+
)
117+
private val K_RIGHT = intArrayOf(
118+
0x50A28BE6, 0x5C4DD124, 0x6D703EF3, 0x7A6D76E9, 0x00000000,
119+
)
120+
121+
// Message-word selection order, left and right lines.
122+
private val R_LEFT = intArrayOf(
123+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
124+
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
125+
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
126+
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
127+
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
128+
)
129+
private val R_RIGHT = intArrayOf(
130+
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
131+
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
132+
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
133+
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
134+
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
135+
)
136+
137+
// Per-step left-rotation amounts, left and right lines.
138+
private val S_LEFT = intArrayOf(
139+
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
140+
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
141+
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
142+
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
143+
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
144+
)
145+
private val S_RIGHT = intArrayOf(
146+
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
147+
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
148+
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
149+
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
150+
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
151+
)
152+
}

‎packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class IdentityKeyPrivateKeyDeriver(
5050
publicKeyData: ByteArray,
5151
identityIndex: Int,
5252
keyIndex: Int,
53+
keyType: Int,
5354
force: Boolean,
5455
): DerivedKeyStoreResult {
5556
val pubkeyHex = publicKeyData.toHex()
@@ -102,7 +103,7 @@ class IdentityKeyPrivateKeyDeriver(
102103
val derivedPrivate = pair[0]
103104
val derivedPublic = pair[1]
104105
scalar = derivedPrivate
105-
if (!derivedPublicKeyMatches(derivedPublic, publicKeyData)) {
106+
if (!derivedPublicKeyMatches(derivedPublic, publicKeyData, keyType)) {
106107
// Scrub the wrong scalar immediately; the finally scrubs
107108
// again harmlessly (idempotent zero-fill).
108109
derivedPrivate.fill(0)
@@ -164,17 +165,35 @@ class IdentityKeyPrivateKeyDeriver(
164165
/** Matches `WalletStorage`'s private `PRIVKEY_PREFIX`. */
165166
const val PRIVKEY_IDENTIFIER_PREFIX = "privkey."
166167

168+
/** DPP `KeyType` discriminants whose on-chain data is a HASH160. */
169+
private const val KEY_TYPE_ECDSA_HASH160 = 2
170+
private const val KEY_TYPE_EDDSA_25519_HASH160 = 4
171+
167172
/**
168-
* Whether a freshly derived public key [derived] is byte-for-byte the
169-
* key [expected] a repair was asked to restore. Pure and side-effect
170-
* free so the repair's before-persistence identity check
173+
* Whether a freshly derived public key [derived] is the key [expected]
174+
* a repair was asked to restore, interpreted per [keyType]. Pure and
175+
* side-effect free so the repair's before-persistence identity check
171176
* (dashpay/platform#4060 blocker 1) is unit-testable without the
172-
* native derive. Both halves are the compressed public-key bytes Rust
173-
* emits (identical encoding on both derive entry points), so a plain
174-
* content comparison is the whole check.
177+
* native derive.
178+
*
179+
* [derived] is always the compressed public-key bytes Rust emits. What
180+
* [expected] holds depends on the key type (dashpay/platform#4183
181+
* review):
182+
* - For `ECDSA_HASH160` / `EDDSA_25519_HASH160`, DPP stores the 20-byte
183+
* HASH160 of the public key as the key's on-chain data — NOT the key
184+
* itself — so we must HASH160 the derived pubkey before comparing.
185+
* A raw byte compare (33-byte pubkey vs 20-byte hash) can never
186+
* match, which made these key types permanently un-repairable before
187+
* this fix.
188+
* - For every other key type the stored data IS the public key, so a
189+
* plain content comparison is the whole check.
175190
*/
176-
fun derivedPublicKeyMatches(derived: ByteArray, expected: ByteArray): Boolean =
177-
derived.contentEquals(expected)
191+
fun derivedPublicKeyMatches(derived: ByteArray, expected: ByteArray, keyType: Int): Boolean =
192+
when (keyType) {
193+
KEY_TYPE_ECDSA_HASH160, KEY_TYPE_EDDSA_25519_HASH160 ->
194+
Hash160.hash160(derived).contentEquals(expected)
195+
else -> derived.contentEquals(expected)
196+
}
178197
}
179198
}
180199

‎packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,7 @@ class PlatformWalletPersistenceHandlerTest {
779779
publicKeyData: ByteArray,
780780
identityIndex: Int,
781781
keyIndex: Int,
782+
keyType: Int,
782783
force: Boolean,
783784
): DerivedKeyStoreResult? {
784785
calls.add(Triple(walletId, identityIndex, keyIndex))
@@ -1021,6 +1022,7 @@ class PlatformWalletPersistenceHandlerTest {
10211022
publicKeyData: ByteArray,
10221023
identityIndex: Int,
10231024
keyIndex: Int,
1025+
keyType: Int,
10241026
force: Boolean,
10251027
): DerivedKeyStoreResult = throw IllegalStateException("keystore unavailable")
10261028

@@ -1092,6 +1094,7 @@ class PlatformWalletPersistenceHandlerTest {
10921094
publicKeyData: ByteArray,
10931095
identityIndex: Int,
10941096
keyIndex: Int,
1097+
keyType: Int,
10951098
force: Boolean,
10961099
): DerivedKeyStoreResult =
10971100
if (boom) {
@@ -1199,6 +1202,7 @@ class PlatformWalletPersistenceHandlerTest {
11991202
publicKeyData: ByteArray,
12001203
identityIndex: Int,
12011204
keyIndex: Int,
1205+
keyType: Int,
12021206
force: Boolean,
12031207
): DerivedKeyStoreResult =
12041208
if (boom) {
@@ -1409,6 +1413,7 @@ class PlatformWalletPersistenceHandlerTest {
14091413
publicKeyData: ByteArray,
14101414
identityIndex: Int,
14111415
keyIndex: Int,
1416+
keyType: Int,
14121417
force: Boolean,
14131418
): DerivedKeyStoreResult? {
14141419
if (!force) {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.dashfoundation.dashsdk.security
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
/**
7+
* Pins [Hash160] against the RIPEMD-160 reference vectors
8+
* (Dobbertin/Bosselaers/Preneel) and the canonical Bitcoin/Dash HASH160 =
9+
* `RIPEMD160(SHA256(pubkey))` composition DPP uses for `ECDSA_HASH160` /
10+
* `EDDSA_25519_HASH160` identity keys. If this drifts, the identity-key
11+
* repair ownership check for HASH160-typed keys (dashpay/platform#4183) would
12+
* compare against a wrong hash and either wrongly reject a valid repair or
13+
* (worse) wrongly accept a mismatched key.
14+
*/
15+
class Hash160Test {
16+
17+
private fun hex(bytes: ByteArray): String =
18+
bytes.joinToString("") { "%02x".format(it) }
19+
20+
private fun unhex(s: String): ByteArray =
21+
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
22+
23+
@Test
24+
fun ripemd160ReferenceVectors() {
25+
assertEquals("9c1185a5c5e9fc54612808977ee8f548b2258d31", hex(Hash160.ripemd160(ByteArray(0))))
26+
assertEquals("8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", hex(Hash160.ripemd160("abc".toByteArray())))
27+
assertEquals(
28+
"5d0689ef49d2fae572b881b123a85ffa21595f36",
29+
hex(Hash160.ripemd160("message digest".toByteArray())),
30+
)
31+
assertEquals(
32+
"12a053384a9c0c88e405a06c27dcf49ada62eb2b",
33+
hex(Hash160.ripemd160("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq".toByteArray())),
34+
)
35+
}
36+
37+
@Test
38+
fun ripemd160MultiBlockAndMillionA() {
39+
// 80 bytes — crosses the 64-byte block boundary.
40+
assertEquals(
41+
"9b752e45573d4b39f4dbd3323cab82bf63326bfb",
42+
hex(Hash160.ripemd160("1234567890".repeat(8).toByteArray())),
43+
)
44+
assertEquals(
45+
"52783243c1697bdbe16d37f97f68f08325dc1528",
46+
hex(Hash160.ripemd160(ByteArray(1_000_000) { 'a'.code.toByte() })),
47+
)
48+
}
49+
50+
@Test
51+
fun hash160OfKnownCompressedPubkey() {
52+
// Canonical Bitcoin/Dash vector: the compressed secp256k1 pubkey for
53+
// the classic sipa example private key. This is exactly the shape a
54+
// HASH160-typed identity key stores on-chain as its "public key data".
55+
val pubkey = unhex("0250863ad64a87ae8a2fe83c1af1a8403cb53f53e486d8511dad8a04887e5b2352")
56+
assertEquals("f54a5851e9372b87810a8e60cdd2e7cfd80b6e31", hex(Hash160.hash160(pubkey)))
57+
}
58+
}

0 commit comments

Comments
 (0)