Skip to content
Open
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -470,13 +470,21 @@ class DashDatabaseMigrationTest {
db.close()
}

@Test
fun migrate11To12AddsAuthenticationScope() {
helper.createDatabase(dbName, 11).close()
val db = helper.runMigrationsAndValidate(dbName, 12, true, DashDatabase.MIGRATION_11_12)
db.query("SELECT contractBoundsScope FROM public_keys").close()
db.close()
}

/** The requested contiguous path from the pre-u64 v4 schema to latest. */
@Test
fun migrate4ToLatest() {
helper.createDatabase(dbName, 4).close()
helper.runMigrationsAndValidate(
dbName,
11,
12,
true,
DashDatabase.MIGRATION_4_5,
DashDatabase.MIGRATION_5_6,
Expand All @@ -485,16 +493,17 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
).close()
}

/** The full chain from v1 must also land on a valid v11 schema. */
/** The full chain from v1 must also land on a valid v12 schema. */
@Test
fun migrateAllTheWayFrom1() {
helper.createDatabase(dbName, 1).close()
helper.runMigrationsAndValidate(
dbName,
11,
12,
true,
DashDatabase.MIGRATION_1_2,
DashDatabase.MIGRATION_2_3,
Expand All @@ -506,6 +515,7 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
).close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ abstract class NativePersistenceBridge {

// ── Identity keys ─────────────────────────────────────────────────

/** One `IdentityKeyEntryFFI` upsert. Descriptor `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;)I`. */
/** One `IdentityKeyEntryFFI` upsert. Descriptor `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;[B)I`. */
@Suppress("LongParameterList")
open fun onPersistIdentityKeyUpsert(
walletId: ByteArray,
Expand All @@ -524,6 +524,7 @@ abstract class NativePersistenceBridge {
contractBoundsKind: Byte,
contractBoundsId: ByteArray,
contractBoundsDocumentType: String?,
contractBoundsScope: ByteArray = ByteArray(0),
): Int = 0

/** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */
Expand Down Expand Up @@ -1253,9 +1254,10 @@ class ContactRequestRestoreData(
* `keyType` / `purpose` / `securityLevel` are DPP `repr(u8)` discriminants
* (out-of-range = 255 sentinel → Rust drops the row rather than coercing to
* MASTER/AUTHENTICATION, matching the Swift loader's `UInt8.max` fallback).
* `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType;
* `contractBoundsId` is 32 bytes (or empty for kind 0);
* `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType, 3 Scoped;
* `contractBoundsId` is 32 bytes (or empty for kinds 0 and 3);
* `contractBoundsDocumentType` is non-null only for kind 2.
* Kind 3 carries the complete versioned DPP bytes in `contractBoundsScope`.
*/
class IdentityKeyRestoreData(
@JvmField val keyId: Int,
Expand All @@ -1267,6 +1269,7 @@ class IdentityKeyRestoreData(
@JvmField val contractBoundsKind: Byte,
@JvmField val contractBoundsId: ByteArray,
@JvmField val contractBoundsDocumentType: String?,
@JvmField val contractBoundsScope: ByteArray = ByteArray(0),
)

/** Mirror of `ShieldedNoteRestoreFFI`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ internal object TransactionsNative {
* @param addPubkeysBlob big-endian rows for the keys to add: `u32
* rowCount` then per row `u32 keyId, u8 keyType, u8 purpose, u8
* securityLevel, u8 readOnly, u8 contractBoundsKind, u16 pubkeyLen,
* pubkey`, plus (when `contractBoundsKind != 0`) a 32-byte contract id
* and (when `== 2`) `u16 docTypeLen, docType`. May be empty.
* pubkey`, plus (for kinds 1 and 2) a 32-byte contract id
* and (when `== 2`) `u16 docTypeLen, docType`; kind 3 instead carries
* `u16 scopeLen, scopeBytes`. May be empty.
* @param disablePublicKeyIds key ids to disable; may be empty. At least
* one of add / disable must be non-empty.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ import java.io.DataOutputStream
* u8 purpose (DPP Purpose discriminant, 0 = AUTHENTICATION)
* u8 securityLevel (DPP SecurityLevel discriminant, 0 = MASTER)
* u8 readOnly (0 / 1)
* u8 contractBoundsKind (0 none, 1 SingleContract, 2 SingleContractDocumentType)
* u8 contractBoundsKind (0 none, 1 SingleContract, 2 SingleContractDocumentType, 3 Scoped)
* u16 pubkeyLen
* u8[pubkeyLen] pubkeyBytes (compressed pubkey, or 20-byte HASH160)
* if contractBoundsKind != 0:
* if contractBoundsKind == 1 or contractBoundsKind == 2:
* u8[32] contractBoundsId
* if contractBoundsKind == 2:
* u16 docTypeLen, u8[docTypeLen] docType (UTF-8)
* if contractBoundsKind == 3:
* u16 scopeLen, u8[scopeLen] versioned DPP scope bytes
* ```
*/
object IdentityPubkeyCodec {
Expand All @@ -57,6 +59,11 @@ object IdentityPubkeyCodec {
dos.writeShort(k.pubkeyBytes.size)
dos.write(k.pubkeyBytes)
when (val bounds = k.contractBounds) {
is ContractBounds.Scoped -> {
require(bounds.encodedScope.size in 1..2048) { "Invalid scope size" }
dos.writeShort(bounds.encodedScope.size)
dos.write(bounds.encodedScope)
}
null -> Unit
is ContractBounds.SingleContract -> dos.write(bounds.contractId)
is ContractBounds.SingleContractDocumentType -> {
Expand All @@ -71,8 +78,9 @@ object IdentityPubkeyCodec {
return out.toByteArray()
}

/** Discriminant matching the FFI: 0 none, 1 SingleContract, 2 with doc type. */
/** Discriminant matching the FFI: 0 none, 1 SingleContract, 2 with doc type, 3 Scoped. */
internal fun contractBoundsKind(bounds: ContractBounds?): Int = when (bounds) {
is ContractBounds.Scoped -> 3
null -> 0
is ContractBounds.SingleContract -> 1
is ContractBounds.SingleContractDocumentType -> 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ enum class SecurityLevel(val ffiValue: Int) {
}

/**
* Contract-bounds shape for an ENCRYPTION / DECRYPTION key — Kotlin mirror
* of Swift's `ManagedPlatformWallet.ContractBounds`. Required by Drive for
* those purposes; omitted (null) for AUTHENTICATION / TRANSFER.
* Kotlin mirror of Swift's `ManagedPlatformWallet.ContractBounds`.
* Legacy variants describe encryption bounds; Scoped carries authentication grants.
*/
sealed class ContractBounds {
/** Versioned scope bytes produced by DPP. Rust validates them on registration. */
data class Scoped(val encodedScope: ByteArray) : ContractBounds() {
override fun equals(other: Any?): Boolean =
other is Scoped && encodedScope.contentEquals(other.encodedScope)
override fun hashCode(): Int = encodedScope.contentHashCode()
}

/** Bind the key to a single contract (any of its document types). */
data class SingleContract(val contractId: ByteArray) : ContractBounds() {
init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,11 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti
* pre-migration row reads back as an ordinary, unstamped, non-tombstone
* entry, and a wallet with no recorded chainlock height has no boundary
* at all (nothing collects).
*
* Version 12 (scoped authentication): preserves encoded contract scopes on public keys.
*/
@Database(
version = 11,
version = 12,
exportSchema = true,
entities = [
WalletEntity::class,
Expand Down Expand Up @@ -542,6 +544,13 @@ abstract class DashDatabase : RoomDatabase() {
}
}

/** v11 → v12: preserve versioned authentication scope bytes. */
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE public_keys ADD COLUMN contractBoundsScope BLOB")
}
}

/** v9 → v10: additive DPNS marketplace state on legacy label rows. */
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
Expand Down Expand Up @@ -635,6 +644,7 @@ abstract class DashDatabase : RoomDatabase() {
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
)
.build()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1863,7 +1863,12 @@ class PlatformWalletPersistenceHandler(
contractBoundsKind: Byte,
contractBoundsId: ByteArray,
contractBoundsDocumentType: String?,
contractBoundsScope: ByteArray,
): Int = guarded {
val boundsKind = contractBoundsKind.toInt() and 0xFF
require(boundsKind in 0..3) { "Unknown contract bounds kind: $boundsKind" }
require(boundsKind != 3 || contractBoundsScope.isNotEmpty()) { "Missing authentication scope" }

// Item 1 — private-key persistence (the CLAUDE.md "one allowed
// exception" shape). The `IdentityKeyEntryFFI` payload carries only
// a derivation breadcrumb (`wallet_id` + `identity_index` +
Expand Down Expand Up @@ -1978,9 +1983,9 @@ class PlatformWalletPersistenceHandler(
val existing = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId)
// ContractBounds projection → the legacy JSON blob column +
// doc-type name (Swift stores `[base64(contractId)]` JSON).
val boundsData = if ((contractBoundsKind.toInt() and 0xFF) != 0)
val boundsData = if (boundsKind in 1..2)
contractBoundsIdToJson(contractBoundsId) else null
val docTypeName = if ((contractBoundsKind.toInt() and 0xFF) == 2)
val docTypeName = if (boundsKind == 2)
contractBoundsDocumentType else null
val row = PublicKeyEntity(
id = existing?.id ?: 0,
Expand All @@ -1993,6 +1998,7 @@ class PlatformWalletPersistenceHandler(
publicKeyData = publicKeyData,
contractBoundsData = boundsData,
contractBoundsDocumentTypeName = docTypeName,
contractBoundsScope = if (boundsKind == 3) contractBoundsScope.copyOf() else null,
// Set to the Keystore identifier when the deriver stored the
// scalar; otherwise preserve any prior identifier (idempotent
// re-persist) and fall back to watch-only (null) for
Expand Down Expand Up @@ -2857,6 +2863,7 @@ class PlatformWalletPersistenceHandler(
// kind 0 rather than crashing FFI marshalling.
val boundsId = pk.contractBoundsData?.let { contractBoundsJsonToId(it) }
val (kind, id) = when {
pk.contractBoundsScope != null -> 3.toByte() to ByteArray(0)
boundsId == null -> 0.toByte() to ByteArray(0)
pk.contractBoundsDocumentTypeName != null -> 2.toByte() to boundsId
else -> 1.toByte() to boundsId
Expand All @@ -2869,6 +2876,7 @@ class PlatformWalletPersistenceHandler(
readOnly = pk.readOnly,
data = pk.publicKeyData,
contractBoundsKind = kind,
contractBoundsScope = pk.contractBoundsScope ?: ByteArray(0),
contractBoundsId = id,
contractBoundsDocumentType =
if (kind.toInt() == 2) pk.contractBoundsDocumentTypeName else null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ data class PublicKeyEntity(
val contractBoundsData: ByteArray? = null,
/** Document-type qualifier for `.singleContractDocumentType` bounds. */
val contractBoundsDocumentTypeName: String? = null,

/** Versioned DPP authentication scope; null for legacy bounds. */
val contractBoundsScope: ByteArray? = null,
val privateKeyKeychainIdentifier: String? = null,
/**
* Derivation breadcrumb (DIP-9 identity index) captured from the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,12 @@ class DashDatabaseTest {
}

@Test
fun schemaIsAtVersion11WithTheSweepHoldIndexes() = runTest {
fun schemaIsAtVersion12WithTheSweepHoldIndexes() = runTest {
// The sweep-hold columns land in ONE migration (10 → 11), with the
// two `pending_inputs` indexes the sweep's claimed-row lookup
// (`spendingTxid`) and the end-of-round collector
// (`walletId, isSweptTombstone, winnerMinedHeight`) rely on.
assertEquals(11, db.openHelper.readableDatabase.version)
assertEquals(12, db.openHelper.readableDatabase.version)
val indexes = mutableSetOf<String>()
db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c ->
val nameColumn = c.getColumnIndexOrThrow("name")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4529,6 +4529,70 @@ class PlatformWalletPersistenceHandlerTest {
assertEquals("contactRequest", key.contractBoundsDocumentType)
}

@Test
fun loadWalletListPreservesScopedAuthenticationBytes() = runTest {
// Signing-critical restore path: a cold-started wallet must get its
// identities and public keys back exactly as persisted — keyId,
// repr(u8) discriminants, key bytes, and the (kind, id, docType)
// contract-bounds triple (kind 2 = SingleContractDocumentType).
handler.onPersistWalletMetadata(walletId, testnet, groupId, 0)
val xpub = ByteArray(78) { 30 }
handler.onPersistAccountRegistration(
walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub,
)
val identityId = ByteArray(32) { 12 }
seedIdentity(identityId)
val pubkey = ByteArray(33) { 7 }
val boundsId = ByteArray(0)
val scope = byteArrayOf(0, 1, 3, 0, 65, 1, 0)

fun persistKey(kind: Byte, scopeBytes: ByteArray) = handler.onPersistIdentityKeyUpsert(
walletId = walletId,
identityId = identityId,
keyId = 4,
purpose = 0,
securityLevel = 2,
keyType = 0,
readOnly = true,
disabledAtIsSome = false,
disabledAt = 0,
publicKeyData = pubkey,
publicKeyHash = ByteArray(20),
walletIdIsSome = true,
keyWalletId = walletId,
derivationIndicesIsSome = false,
identityIndex = 0,
keyIndex = 0,
contractBoundsKind = kind,
contractBoundsId = boundsId,
contractBoundsDocumentType = null,
contractBoundsScope = scopeBytes,
)
assertEquals(1, persistKey(4, scope))
assertEquals(1, persistKey(3, ByteArray(0)))
handler.onChangesetBegin(walletId)
assertEquals(0, persistKey(3, scope))
handler.onChangesetEnd(walletId, success = true)

val list = handler.onLoadWalletList()
assertEquals(1, list.size)
assertEquals(1, list[0].identities.size)
val identity = list[0].identities[0]
assertTrue(identityId.contentEquals(identity.identityId))
assertEquals(1, identity.keys.size)
val key = identity.keys[0]
assertEquals(4, key.keyId)
assertEquals(0.toByte(), key.keyType)
assertEquals(0.toByte(), key.purpose)
assertEquals(2.toByte(), key.securityLevel)
assertTrue(key.readOnly)
assertTrue(pubkey.contentEquals(key.data))
assertEquals(3.toByte(), key.contractBoundsKind)
assertTrue(boundsId.contentEquals(key.contractBoundsId))
assertNull(key.contractBoundsDocumentType)
assertTrue(scope.contentEquals(key.contractBoundsScope))
}

// ── DashPay contacts: upsert metadata, ignore delta, restore ──────

/** Persist one incoming contact row for [senderId] owned by [ownerId]. */
Expand Down Expand Up @@ -6018,7 +6082,7 @@ class PlatformWalletPersistenceHandlerTest {
// A tombstone with a NULL stamp is never collected. The
// mempool-context sweep path writes exactly this shape — an
// IS-locked, unmined winner has no finality horizon to stamp —
// and legacy rows (the v10v11 migration leaves pre-existing
// and legacy rows (the v11v12 migration leaves pre-existing
// tombstones NULL) read identically. With no proof of finality
// the safe reading is to hold it forever rather than guess it
// collectible.
Expand Down
Loading
Loading