diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt index 1fe90dd9615..3e6b8114b6b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt @@ -155,9 +155,12 @@ internal object IdentityNative { /** * DashPay Connect key derivation at the DIP-13 sub-feature paths - * `m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']` - * (dashpay/dips#191). Bridges `dash_sdk_derive_connect_key_with_resolver`, - * a pure resolver-keyed derive like the slot derives above. + * `m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose'/rotation']` + * (dashpay/dips#191). Bridges `dash_sdk_derive_connect_key_v2_with_resolver`, + * a pure resolver-keyed derive like the slot derives above. Renamed from + * `deriveConnectKeyWithResolver` when the rotation argument was added, so a + * stale native library fails with `UnsatisfiedLinkError` instead of + * receiving mismatched arguments. * * @param subFeature 6 (session authentication; [leaf] is the request * id) or 7 (app encryption; [leaf] is the bound contract's id). @@ -165,10 +168,13 @@ internal object IdentityNative { * @param leaf the 32-byte leaf (a DIP-14 hardened child). * @param purpose 0 with sub-feature 6 (no purpose level), or the half * of an encryption pair with sub-feature 7: 1 ENCRYPTION, - * 2 DECRYPTION. Any other combination throws. + * 2 DECRYPTION. + * @param rotation 0 with sub-feature 6 (no rotation level), or the + * non-negative `rotation'` level with sub-feature 7 (0 for the first + * pair). Any other combination throws. * @return `[privateKey(32), publicKey(33)]`. */ - external fun deriveConnectKeyWithResolver( + external fun deriveConnectKeyV2WithResolver( networkOrd: Int, walletId: ByteArray, resolverHandle: Long, @@ -176,6 +182,26 @@ internal object IdentityNative { identityId: ByteArray, leaf: ByteArray, purpose: Int, + rotation: Int, + ): Array + + /** + * Pre-rotation DashPay Connect app encryption key at + * `m/9'/coin'/5'/7'/0'/identityId'/contractId'/purpose'` (no + * `rotation'` level), for recognising keys registered on testnet/devnet + * before DIP-13 gained that level. Bridges + * `dash_sdk_derive_legacy_app_encryption_key_with_resolver`. + * + * @param purpose 1 ENCRYPTION or 2 DECRYPTION; anything else throws. + * @return `[privateKey(32), publicKey(33)]`. + */ + external fun deriveLegacyAppEncryptionKeyWithResolver( + networkOrd: Int, + walletId: ByteArray, + resolverHandle: Long, + identityId: ByteArray, + contractId: ByteArray, + purpose: Int, ): Array /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1fe2572e8fc..e552cdfcdfc 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -642,7 +642,7 @@ class PlatformWalletManager( /** * DIP-13 sub-features DashPay Connect v2 keys live under - * (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`), + * (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose'/rotation']`), * registered by the DIP-13 amendment dashpay/dips#191. Mirror of * Swift's `ManagedPlatformWallet.ConnectSubFeature`. */ @@ -651,13 +651,13 @@ class PlatformWalletManager( SESSION_AUTHENTICATION(6), /** * App encryption key pair: the leaf is the bound data contract's id; - * the purpose level is a [ConnectKeyPurpose]. + * the purpose level is a [ConnectKeyPurpose], followed by a rotation level. */ APP_ENCRYPTION(7), } /** - * The trailing `purpose'` level of the app-encryption path: the DPP + * The `purpose'` level of the app-encryption path: the DPP * purpose discriminant of the half being derived. Only these two exist * in the DIP-13 amendment, so the type makes any other value (in * particular 0, the FFI's "no purpose level") unrepresentable. Mirror @@ -669,15 +669,15 @@ class PlatformWalletManager( } /** - * Derive a DashPay Connect key at - * `m/9'/coin'/5'/'/0'/'/'[/']` - * from the wallet's seed, resolved through the mnemonic resolver — the - * Android analog of Swift's `deriveConnectKey(subFeature:identityId:leaf:purpose:)`. - * [identityId] and [leaf] are DIP-14 256-bit hardened children, so no - * wallet-local counter is an input. [ConnectSubFeature.APP_ENCRYPTION] - * requires a [purpose], which picks the half of its pair; - * [ConnectSubFeature.SESSION_AUTHENTICATION] requires null. Any other - * combination throws. + * Derive the DashPay Connect session authentication key at + * `m/9'/coin'/5'/6'/0'/'/'` from the wallet's seed, + * resolved through the mnemonic resolver — the Android analog of Swift's + * `deriveConnectKey(subFeature:identityId:leaf:network:storage:)`. [identityId] + * and [leaf] (the connect request id) are DIP-14 256-bit hardened + * children, so no wallet-local counter is an input. Only + * [ConnectSubFeature.SESSION_AUTHENTICATION] derives here; app + * encryption keys need a purpose and rotation, so passing + * [ConnectSubFeature.APP_ENCRYPTION] throws. * * @return `(privateKey(32), publicKey(33))`. Caller zeroes the private half. */ @@ -686,7 +686,82 @@ class PlatformWalletManager( subFeature: ConnectSubFeature, identityId: ByteArray, leaf: ByteArray, - purpose: ConnectKeyPurpose? = null, + ): Pair = connectKey(identityId, leaf, "leaf") { + org.dashfoundation.dashsdk.ffi.IdentityNative.deriveConnectKeyV2WithResolver( + network.ffiValue, + walletId, + mnemonicResolver.nativeHandle, + subFeature.value, + identityId, + leaf, + 0, + 0, + ) + } + + /** + * Derive one half of a DashPay Connect app encryption pair at + * `m/9'/coin'/5'/7'/0'/'/'/'/'` — + * the Android analog of Swift's + * `deriveConnectKey(subFeature:identityId:leaf:purpose:rotation:network:storage:)`. + * [leaf] is the bound contract's id; [rotation] is the non-negative + * rotation level, 0 for the first pair. Only + * [ConnectSubFeature.APP_ENCRYPTION] derives here; anything else throws. + * Keys registered before DIP-13 gained the rotation level come from + * [deriveLegacyAppEncryptionKey]. + * + * @return `(privateKey(32), publicKey(33))`. Caller zeroes the private half. + */ + suspend fun deriveConnectKey( + walletId: ByteArray, + subFeature: ConnectSubFeature, + identityId: ByteArray, + leaf: ByteArray, + purpose: ConnectKeyPurpose, + rotation: Int, + ): Pair = connectKey(identityId, leaf, "leaf") { + org.dashfoundation.dashsdk.ffi.IdentityNative.deriveConnectKeyV2WithResolver( + network.ffiValue, + walletId, + mnemonicResolver.nativeHandle, + subFeature.value, + identityId, + leaf, + purpose.value, + rotation, + ) + } + + /** + * Derive one half of a DashPay Connect app encryption pair at the + * pre-rotation layout `m/9'/coin'/5'/7'/0'/'/'/'`. + * Only for recognising keys registered on testnet/devnet before DIP-13 + * gained the rotation level; derive new keys with [deriveConnectKey]. + * Mirror of Swift's `deriveLegacyAppEncryptionKey`. + * + * @return `(privateKey(32), publicKey(33))`. Caller zeroes the private half. + */ + suspend fun deriveLegacyAppEncryptionKey( + walletId: ByteArray, + identityId: ByteArray, + contractId: ByteArray, + purpose: ConnectKeyPurpose, + ): Pair = connectKey(identityId, contractId, "contractId") { + org.dashfoundation.dashsdk.ffi.IdentityNative.deriveLegacyAppEncryptionKeyWithResolver( + network.ffiValue, + walletId, + mnemonicResolver.nativeHandle, + identityId, + contractId, + purpose.value, + ) + } + + private suspend fun connectKey( + identityId: ByteArray, + leaf: ByteArray, + leafName: String, + derive: () -> Array, ): Pair = teardownGate.opWithCleanupOnCancellation( // withContext's prompt cancellation can discard a completed derive // before the caller receives the pair it is documented to wipe; scrub @@ -694,18 +769,8 @@ class PlatformWalletManager( cleanup = { pair -> pair.first.fill(0) }, ) { require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" } - require(leaf.size == 32) { "leaf must be 32 bytes, got ${leaf.size}" } - val pair = org.dashfoundation.dashsdk.errors.mapNativeErrors { - org.dashfoundation.dashsdk.ffi.IdentityNative.deriveConnectKeyWithResolver( - network.ffiValue, - walletId, - mnemonicResolver.nativeHandle, - subFeature.value, - identityId, - leaf, - purpose?.value ?: 0, - ) - } + require(leaf.size == 32) { "$leafName must be 32 bytes, got ${leaf.size}" } + val pair = org.dashfoundation.dashsdk.errors.mapNativeErrors(derive) check(pair.size == 2) { "connect key derive returned ${pair.size} elements" } pair[0] to pair[1] } diff --git a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs index bc56406d908..6de65bdcaeb 100644 --- a/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs +++ b/packages/rs-platform-wallet-ffi/src/derive_connect_key.rs @@ -7,9 +7,14 @@ //! //! ```text //! session auth: m / 9' / coin' / 5' / 6' / 0' / identityId' / requestId' -//! app encryption: m / 9' / coin' / 5' / 7' / 0' / identityId' / contractId' / purpose' +//! app encryption: m / 9' / coin' / 5' / 7' / 0' / identityId' / contractId' / purpose' / rotation' //! ``` //! +//! [`dash_sdk_derive_legacy_app_encryption_key_with_resolver`] derives the +//! app encryption key at the pre-rotation layout (no `rotation'` level), so +//! wallets can recognise keys registered on testnet/devnet before DIP-13 +//! gained that level. +//! //! The wallet derives every key; the app never sees the seed. This module //! is the FFI mirror of //! [`platform_wallet::wallet::identity::network::derive_connect_keypair_from_master`] @@ -34,12 +39,13 @@ use zeroize::Zeroize; /// `sub_feature` for the DashPay Connect session authentication key. The /// leaf is the connect request id (`hash256` of the app's ephemeral public -/// key); `purpose` is `0`. +/// key); `purpose` and `rotation` are `0`. pub const CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION: u32 = 6; /// `sub_feature` for the DashPay Connect app encryption key pair. The leaf /// is the id of the data contract the key is bound to; `purpose` is the -/// DPP purpose discriminant, `1` ENCRYPTION or `2` DECRYPTION. +/// DPP purpose discriminant, `1` ENCRYPTION or `2` DECRYPTION, and +/// `rotation` the trailing `rotation'` level. pub const CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION: u32 = 7; /// `purpose` for the ENCRYPTION half of an app encryption pair (the DPP @@ -92,15 +98,24 @@ impl Default for ConnectDerivedKeyFFI { /// Derive a DashPay Connect key from the mnemonic the resolver returns for /// `wallet_id_bytes`, at the DIP-13 application paths key-wallet builds. +/// This is the rotation-aware entry point: the pre-rotation +/// `dash_sdk_derive_connect_key_with_resolver` (no `rotation` argument) is +/// gone, so a caller compiled against it fails to link rather than passing +/// its `out_key` where `rotation` now sits. /// -/// - `sub_feature` `6` with `purpose` `0`: the session authentication key; -/// `leaf_bytes` is the connect request id. +/// - `sub_feature` `6` with `purpose` `0` and `rotation` `0`: the session +/// authentication key; `leaf_bytes` is the connect request id. The path +/// has no purpose or rotation level. /// - `sub_feature` `7` with `purpose` `1` (ENCRYPTION) or `2` (DECRYPTION): /// one half of the app encryption pair; `leaf_bytes` is the bound contract -/// id. +/// id and `rotation` is the trailing `rotation'` level (`0` for the first +/// pair, at most `2^31 - 1`). /// -/// Any other combination is refused with `ErrorInvalidParameter`. -/// `network` picks the coin type (`5'` mainnet, `1'` otherwise). +/// Anything else is refused with `ErrorInvalidParameter` (platform-wallet's +/// `ConnectKey::from_parts` decides) before the seed is resolved. `network` +/// picks the coin type (`5'` mainnet, `1'` otherwise). Keys at the +/// pre-rotation app encryption layout come from +/// [`dash_sdk_derive_legacy_app_encryption_key_with_resolver`] instead. /// /// The derived key is written to `out_key`; call /// [`dash_sdk_derive_connect_key_free`] when done with it. @@ -112,7 +127,8 @@ impl Default for ConnectDerivedKeyFFI { /// `dash_sdk_mnemonic_resolver_create`. /// - `out_key` must be a valid, writable pointer. #[no_mangle] -pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn dash_sdk_derive_connect_key_v2_with_resolver( network: FFINetwork, wallet_id_bytes: *const u8, mnemonic_resolver_handle: *mut MnemonicResolverHandle, @@ -120,48 +136,112 @@ pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( identity_id_bytes: *const u8, leaf_bytes: *const u8, purpose: u32, + rotation: u32, out_key: *mut ConnectDerivedKeyFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_key); *out_key = ConnectDerivedKeyFFI::empty(); - check_ptr!(wallet_id_bytes); - check_ptr!(mnemonic_resolver_handle); check_ptr!(leaf_bytes); + let identity_id = unwrap_result_or_return!(read_identifier(identity_id_bytes)); + let key = unwrap_result_or_return!(ConnectKey::from_parts( + sub_feature, + identity_id, + read_leaf(leaf_bytes), + purpose, + rotation, + )); + + derive_into( + network, + wallet_id_bytes, + mnemonic_resolver_handle, + key, + out_key, + ) +} +/// Derive one half of a DashPay Connect app encryption pair at the +/// pre-rotation DIP-13 layout +/// `m/9'/coin'/5'/7'/0'/identityId'/contractId'/purpose'`, which has no +/// trailing `rotation'` level. Only for recognising keys registered on +/// testnet/devnet before DIP-13 gained that level; derive new keys with +/// [`dash_sdk_derive_connect_key_v2_with_resolver`]. +/// +/// `purpose` is `1` (ENCRYPTION) or `2` (DECRYPTION); anything else is +/// refused with `ErrorInvalidParameter` before the seed is resolved. +/// `contract_id_bytes` is the bound contract id. `network` picks the coin +/// type (`5'` mainnet, `1'` otherwise). +/// +/// The derived key is written to `out_key`; call +/// [`dash_sdk_derive_connect_key_free`] when done with it. +/// +/// # Safety +/// - `wallet_id_bytes`, `identity_id_bytes` and `contract_id_bytes` must +/// point to 32 readable bytes each. +/// - `mnemonic_resolver_handle` must be a live handle from +/// `dash_sdk_mnemonic_resolver_create`. +/// - `out_key` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_derive_legacy_app_encryption_key_with_resolver( + network: FFINetwork, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + identity_id_bytes: *const u8, + contract_id_bytes: *const u8, + purpose: u32, + out_key: *mut ConnectDerivedKeyFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_key); + *out_key = ConnectDerivedKeyFFI::empty(); + + check_ptr!(contract_id_bytes); let identity_id = unwrap_result_or_return!(read_identifier(identity_id_bytes)); - let leaf: [u8; 32] = std::slice::from_raw_parts(leaf_bytes, 32) + let key = unwrap_result_or_return!(ConnectKey::legacy_app_encryption_from_parts( + identity_id, + read_leaf(contract_id_bytes), + purpose, + )); + + derive_into( + network, + wallet_id_bytes, + mnemonic_resolver_handle, + key, + out_key, + ) +} + +/// Copy the 32-byte leaf (request or contract id) behind `leaf_bytes`. +/// +/// # Safety +/// `leaf_bytes` must be non-null (the caller `check_ptr!`s it) and point to +/// at least 32 readable, initialised bytes. +unsafe fn read_leaf(leaf_bytes: *const u8) -> [u8; 32] { + std::slice::from_raw_parts(leaf_bytes, 32) .try_into() - .expect("from_raw_parts(_, 32) always yields exactly 32 bytes"); - let key = match (sub_feature, purpose) { - (CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, 0) => ConnectKey::SessionAuthentication { - identity_id, - request_id: leaf, - }, - (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_PURPOSE_ENCRYPTION) => { - ConnectKey::AppEncryption { - identity_id, - contract_id: leaf, - purpose: ApplicationKeyPurpose::Encryption, - } - } - (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, CONNECT_KEY_PURPOSE_DECRYPTION) => { - ConnectKey::AppEncryption { - identity_id, - contract_id: leaf, - purpose: ApplicationKeyPurpose::Decryption, - } - } - (sub_feature, purpose) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "sub_feature {sub_feature} with purpose {purpose} is not a DashPay Connect \ - key (6 with purpose 0, or 7 with purpose 1 or 2)" - ), - ); - } - }; + .expect("from_raw_parts(_, 32) always yields exactly 32 bytes") +} + +/// Resolve the wallet's master key, derive `key` and write it to `out_key` +/// (already reset to empty by the caller). +/// +/// # Safety +/// - `wallet_id_bytes` must be null (refused here) or point to at least 32 +/// readable bytes. +/// - `mnemonic_resolver_handle` must be null (refused here) or a live handle +/// from `dash_sdk_mnemonic_resolver_create`. +/// - `out_key` must be a valid, writable pointer (the caller `check_ptr!`s +/// it). +unsafe fn derive_into( + network: FFINetwork, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + key: ConnectKey, + out_key: *mut ConnectDerivedKeyFFI, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); let kw_network: Network = network.into(); let wallet_id: [u8; 32] = std::slice::from_raw_parts(wallet_id_bytes, 32) @@ -185,7 +265,8 @@ pub unsafe extern "C" fn dash_sdk_derive_connect_key_with_resolver( PlatformWalletFFIResult::ok() } -/// Wipe a key populated by [`dash_sdk_derive_connect_key_with_resolver`]. +/// Wipe a key populated by [`dash_sdk_derive_connect_key_v2_with_resolver`] or +/// [`dash_sdk_derive_legacy_app_encryption_key_with_resolver`]. /// The struct owns no heap memory; this zeroizes the private scalar in /// place (a volatile write the optimizer cannot elide) and clears the /// public key. Safe on a null pointer and on an already-wiped key. @@ -253,11 +334,12 @@ mod tests { sub_feature: u32, leaf: [u8; 32], purpose: u32, + rotation: u32, ) -> (PlatformWalletFFIResult, ConnectDerivedKeyFFI) { let wallet_id = [0x07u8; 32]; let mut out = ConnectDerivedKeyFFI::empty(); let result = unsafe { - dash_sdk_derive_connect_key_with_resolver( + dash_sdk_derive_connect_key_v2_with_resolver( network, wallet_id.as_ptr(), resolver, @@ -265,6 +347,29 @@ mod tests { IDENTITY.as_ptr(), leaf.as_ptr(), purpose, + rotation, + &mut out, + ) + }; + (result, out) + } + + fn derive_legacy( + resolver: *mut MnemonicResolverHandle, + network: FFINetwork, + contract_id: [u8; 32], + purpose: u32, + ) -> (PlatformWalletFFIResult, ConnectDerivedKeyFFI) { + let wallet_id = [0x07u8; 32]; + let mut out = ConnectDerivedKeyFFI::empty(); + let result = unsafe { + dash_sdk_derive_legacy_app_encryption_key_with_resolver( + network, + wallet_id.as_ptr(), + resolver, + IDENTITY.as_ptr(), + contract_id.as_ptr(), + purpose, &mut out, ) }; @@ -286,6 +391,7 @@ mod tests { CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0, + 0, ); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); let (result, second) = derive( @@ -294,6 +400,7 @@ mod tests { CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0, + 0, ); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert_eq!(first.private_key_bytes, second.private_key_bytes); @@ -316,28 +423,94 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + /// Both app encryption entry points derive the DIP-13 pinned vectors + /// (mainnet, identity 0x35.., contract 0x6b..): the rotated layout at + /// rotations 0 and 1, and the pre-rotation layout. + #[test] + fn app_encryption_entry_points_derive_the_pinned_vectors() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) + }; + let check = |(result, out): (PlatformWalletFFIResult, ConnectDerivedKeyFFI), expected| { + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(hex::encode(out.public_key_bytes), expected); + }; + + for (rotation, expected) in [ + ( + 0, + "031a882c119e2b1f8e0349b444d877afa2599efeef49a26930fd25518cb6e9d57a", + ), + ( + 1, + "03d0f6f0f48d968b9554d8e7fe467d7135bacf29d5c4994ca593bd10ebeed696f6", + ), + ] { + check( + derive( + resolver, + FFINetwork::Mainnet, + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + LEAF, + CONNECT_KEY_PURPOSE_ENCRYPTION, + rotation, + ), + expected, + ); + } + check( + derive_legacy( + resolver, + FFINetwork::Mainnet, + LEAF, + CONNECT_KEY_PURPOSE_ENCRYPTION, + ), + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434", + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + /// Leaf, purpose and sub-feature each change the key. #[test] fn different_leaves_purposes_and_sub_features_give_different_keys() { let resolver = unsafe { dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), test_resolve, noop_destroy) }; - let key = |sub_feature, leaf, purpose| { - let (result, out) = derive(resolver, FFINetwork::Testnet, sub_feature, leaf, purpose); + let key = |sub_feature, leaf, purpose, rotation| { + let (result, out) = derive( + resolver, + FFINetwork::Testnet, + sub_feature, + leaf, + purpose, + rotation, + ); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); out.public_key_bytes }; - let auth = key(CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0); + let auth = key(CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0, 0); let auth_other_leaf = key( CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, [0x6C; 32], 0, + 0, ); - let enc = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 1); - let dec = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 2); + let enc = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 1, 0); + let dec = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 2, 0); + let enc_next_rotation = key(CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 1, 1); + let (result, legacy) = derive_legacy(resolver, FFINetwork::Testnet, LEAF, 1); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - let all = [auth, auth_other_leaf, enc, dec]; + let all = [ + auth, + auth_other_leaf, + enc, + dec, + enc_next_rotation, + legacy.public_key_bytes, + ]; for (i, a) in all.iter().enumerate() { for b in &all[i + 1..] { assert_ne!(a, b, "connect keys collided (index {i})"); @@ -358,6 +531,7 @@ mod tests { CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, LEAF, 2, + 0, ); assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert_ne!(out.private_key_bytes, [0u8; 32]); @@ -382,6 +556,7 @@ mod tests { CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, LEAF, 0, + 0, ); assert_eq!( result.code, @@ -399,7 +574,7 @@ mod tests { let mut out = ConnectDerivedKeyFFI::empty(); let wallet_id = [0x07u8; 32]; let result = unsafe { - dash_sdk_derive_connect_key_with_resolver( + dash_sdk_derive_connect_key_v2_with_resolver( FFINetwork::Testnet, wallet_id.as_ptr(), std::ptr::null_mut(), @@ -407,6 +582,7 @@ mod tests { IDENTITY.as_ptr(), LEAF.as_ptr(), 0, + 0, &mut out, ) }; @@ -421,7 +597,7 @@ mod tests { let mut out = ConnectDerivedKeyFFI::empty(); let wallet_id = [0x07u8; 32]; let result = unsafe { - dash_sdk_derive_connect_key_with_resolver( + dash_sdk_derive_connect_key_v2_with_resolver( FFINetwork::Testnet, wallet_id.as_ptr(), std::ptr::dangling_mut(), @@ -429,6 +605,7 @@ mod tests { std::ptr::null(), LEAF.as_ptr(), 0, + 0, &mut out, ) }; @@ -436,21 +613,45 @@ mod tests { assert_eq!(out.private_key_bytes, [0u8; 32]); } - /// Only session authentication with no purpose, and app encryption with - /// ENCRYPTION or DECRYPTION, derive; anything else is refused before the - /// seed is resolved. + /// Only session authentication with no purpose or rotation, and app + /// encryption with ENCRYPTION or DECRYPTION at a 31-bit rotation, derive; + /// anything else is refused before the seed is resolved (the dangling + /// resolver handle is never touched). #[test] - fn other_sub_feature_and_purpose_combinations_are_invalid_parameters() { - for (sub_feature, purpose) in [ - (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 3), - (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 0), - (CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, 1), - (5, 0), + fn other_sub_feature_purpose_and_rotation_combinations_are_invalid_parameters() { + for (sub_feature, purpose, rotation, expected) in [ + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 3, 0, "purpose 3"), + (CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, 0, 0, "purpose 0"), + ( + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + 1, + 0x8000_0000, + "rotation 2147483648", + ), + ( + CONNECT_KEY_SUB_FEATURE_APP_ENCRYPTION, + 2, + u32::MAX, + "rotation 4294967295", + ), + ( + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + 1, + 0, + "sub_feature 6 with purpose 1 and rotation 0", + ), + ( + CONNECT_KEY_SUB_FEATURE_SESSION_AUTHENTICATION, + 0, + 1, + "sub_feature 6 with purpose 0 and rotation 1", + ), + (5, 0, 0, "sub_feature 5 with purpose 0 and rotation 0"), ] { let mut out = ConnectDerivedKeyFFI::empty(); let wallet_id = [0x07u8; 32]; let mut result = unsafe { - dash_sdk_derive_connect_key_with_resolver( + dash_sdk_derive_connect_key_v2_with_resolver( FFINetwork::Testnet, wallet_id.as_ptr(), std::ptr::dangling_mut(), @@ -458,19 +659,35 @@ mod tests { IDENTITY.as_ptr(), LEAF.as_ptr(), purpose, + rotation, &mut out, ) }; assert_eq!( result.code, PlatformWalletFFIResultCode::ErrorInvalidParameter, - "sub_feature {sub_feature} purpose {purpose}" + "sub_feature {sub_feature} purpose {purpose} rotation {rotation}" ); let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); - assert!( - message.contains(&format!("sub_feature {sub_feature} with purpose {purpose}")), - "{message}" + assert!(message.contains(expected), "{message}"); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } + } + + /// The legacy entry point takes only ENCRYPTION or DECRYPTION, refused + /// before the seed is resolved. + #[test] + fn legacy_app_encryption_refuses_other_purposes() { + for purpose in [0, 3] { + let (mut result, out) = + derive_legacy(std::ptr::dangling_mut(), FFINetwork::Testnet, LEAF, purpose); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter ); + let message = unsafe { CStr::from_ptr(result.message) }.to_str().unwrap(); + assert!(message.contains(&format!("purpose {purpose}")), "{message}"); + assert_eq!(out.private_key_bytes, [0u8; 32]); unsafe { platform_wallet_ffi_result_free(&mut result) }; } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index ff30931ed2c..64d4e91116a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -31,6 +31,8 @@ use key_wallet::bip32::{ ApplicationKeyPurpose, ChildNumber, DerivationPath, ExtendedPrivKey, KeyDerivationType, }; use key_wallet::dip9::{ + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION, + FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION, IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; use key_wallet::wallet::Wallet; @@ -128,41 +130,167 @@ pub enum ConnectKey { identity_id: Identifier, request_id: [u8; 32], }, - /// `m/9'/coin'/5'/7'/0'/identity_id'/contract_id'/purpose'`: one half of - /// the encryption pair an identity holds for a contract. + /// `m/9'/coin'/5'/7'/0'/identity_id'/contract_id'/purpose'/rotation'`: + /// one half of the encryption pair an identity holds for a contract. + /// `rotation` is a 31-bit hardened index, `0` for the first pair; each + /// rotation is a fresh pair. Derive new keys with this variant. AppEncryption { identity_id: Identifier, contract_id: [u8; 32], purpose: ApplicationKeyPurpose, + rotation: u32, + }, + /// The pre-rotation layout + /// `m/9'/coin'/5'/7'/0'/identity_id'/contract_id'/purpose'`, without the + /// trailing `rotation'` level. Only for recognising keys registered on + /// testnet/devnet before DIP-13 gained that level; never derive new keys + /// here. + AppEncryptionLegacy { + identity_id: Identifier, + contract_id: [u8; 32], + purpose: ApplicationKeyPurpose, }, } impl ConnectKey { - /// The key's derivation path on `network`. - pub fn derivation_path(&self, network: key_wallet::Network) -> DerivationPath { + /// Build a key from the raw DIP-13 levels a foreign caller (FFI, JNI) + /// passes, validating the combination: + /// + /// - `sub_feature` `6` with `purpose` `0` and `rotation` `0`: + /// [`SessionAuthentication`](Self::SessionAuthentication), `leaf` is the + /// request id (the path has no purpose or rotation level); + /// - `sub_feature` `7` with `purpose` `1` (ENCRYPTION) or `2` + /// (DECRYPTION) and a 31-bit `rotation`: + /// [`AppEncryption`](Self::AppEncryption), `leaf` is the contract id. + /// + /// Anything else is [`PlatformWalletError::InvalidParameter`]. The + /// pre-rotation layout is never selected here; it has its own explicit + /// constructor, [`legacy_app_encryption_from_parts`](Self::legacy_app_encryption_from_parts). + pub fn from_parts( + sub_feature: u32, + identity_id: Identifier, + leaf: [u8; 32], + purpose: u32, + rotation: u32, + ) -> Result { + match (sub_feature, purpose, rotation) { + (FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION, 0, 0) => { + Ok(ConnectKey::SessionAuthentication { + identity_id, + request_id: leaf, + }) + } + (FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION, purpose, rotation) => { + let purpose = app_encryption_purpose(purpose)?; + hardened_rotation(rotation)?; + Ok(ConnectKey::AppEncryption { + identity_id, + contract_id: leaf, + purpose, + rotation, + }) + } + (sub_feature, purpose, rotation) => { + Err(PlatformWalletError::InvalidParameter(format!( + "sub_feature {sub_feature} with purpose {purpose} and rotation {rotation} is not \ + a DashPay Connect key (6 with purpose 0 and rotation 0, or 7 with purpose 1 \ + or 2)" + ))) + } + } + } + + /// Build a pre-rotation [`AppEncryptionLegacy`](Self::AppEncryptionLegacy) + /// key from a raw `purpose` (`1` ENCRYPTION or `2` DECRYPTION); anything + /// else is [`PlatformWalletError::InvalidParameter`]. + pub fn legacy_app_encryption_from_parts( + identity_id: Identifier, + contract_id: [u8; 32], + purpose: u32, + ) -> Result { + Ok(ConnectKey::AppEncryptionLegacy { + identity_id, + contract_id, + purpose: app_encryption_purpose(purpose)?, + }) + } + + /// The key's derivation path on `network`. Fails with + /// [`PlatformWalletError::InvalidParameter`] when an + /// [`AppEncryption`](Self::AppEncryption) rotation does not fit a + /// hardened index (`rotation >= 2^31`). + pub fn derivation_path( + &self, + network: key_wallet::Network, + ) -> Result { match *self { ConnectKey::SessionAuthentication { identity_id, request_id, - } => DerivationPath::application_session_authentication_path( + } => Ok(DerivationPath::application_session_authentication_path( network, identity_id.to_buffer(), request_id, - ), + )), ConnectKey::AppEncryption { identity_id, contract_id, purpose, - } => DerivationPath::application_encryption_path( + rotation, + } => { + let rotation = hardened_rotation(rotation)?; + // Temporary: the pinned rust-dashcore builds only the + // pre-rotation path, so the rotation level is appended here. + // dashpay/rust-dashcore#1250 adds the level to key-wallet; + // once platform bumps to a rev with it, replace this arm with + // `DerivationPath::application_encryption_path(network, + // identity_id, contract_id, purpose, rotation)` and the + // legacy arm with `application_encryption_path_without_rotation`. + // `connect_keypairs_match_the_pinned_vectors` keeps that swap + // a no-op. + Ok(DerivationPath::application_encryption_path( + network, + identity_id.to_buffer(), + contract_id, + purpose, + ) + .extend([rotation])) + } + ConnectKey::AppEncryptionLegacy { + identity_id, + contract_id, + purpose, + } => Ok(DerivationPath::application_encryption_path( network, identity_id.to_buffer(), contract_id, purpose, - ), + )), } } } +/// The app encryption half a raw DIP-13 `purpose` level names. +fn app_encryption_purpose(purpose: u32) -> Result { + match purpose { + p if p == ApplicationKeyPurpose::Encryption as u32 => Ok(ApplicationKeyPurpose::Encryption), + p if p == ApplicationKeyPurpose::Decryption as u32 => Ok(ApplicationKeyPurpose::Decryption), + purpose => Err(PlatformWalletError::InvalidParameter(format!( + "purpose {purpose} is not an app encryption purpose (1 ENCRYPTION or 2 DECRYPTION)" + ))), + } +} + +/// The hardened `rotation'` level; a rotation of `2^31` or more does not fit +/// one and is refused rather than wrapped onto another key. +fn hardened_rotation(rotation: u32) -> Result { + ChildNumber::from_hardened_idx(rotation).map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "Invalid app encryption rotation {rotation}: {e}" + )) + }) +} + /// Derive the ECDSA secp256k1 keypair of `key` from a master xpriv. Pure, /// like [`derive_ecdsa_identity_auth_keypair_from_master`], so it works for /// watch-only wallets whose seed the FFI resolves on demand. @@ -173,7 +301,7 @@ pub fn derive_connect_keypair_from_master( ) -> Result { use key_wallet::bip32::ExtendedPubKey; - let path = key.derivation_path(network); + let path = key.derivation_path(network)?; // See `derive_ecdsa_identity_auth_keypair_from_master` for why the // intermediate `ExtendedPrivKey` needs no explicit wipe. let derived = master.derive_priv(&path).map_err(|e| { @@ -731,20 +859,54 @@ mod tests { } } - fn encryption_key(purpose: ApplicationKeyPurpose) -> ConnectKey { + fn encryption_key(purpose: ApplicationKeyPurpose, rotation: u32) -> ConnectKey { ConnectKey::AppEncryption { identity_id: Identifier::from(CONNECT_IDENTITY), contract_id: CONNECT_LEAF, purpose, + rotation, } } - /// The keys key-wallet pins for these inputs; a change orphans every key - /// already registered on-chain. The public key is the compressed point of - /// the returned scalar. + fn legacy_encryption_key(purpose: ApplicationKeyPurpose) -> ConnectKey { + ConnectKey::AppEncryptionLegacy { + identity_id: Identifier::from(CONNECT_IDENTITY), + contract_id: CONNECT_LEAF, + purpose, + } + } + + /// `00 01 .. 1f` and `ff fe .. e0`: asymmetric 256-bit children, so a + /// swapped identity/contract level or a byte-order slip changes the key. + fn ascending() -> [u8; 32] { + std::array::from_fn(|i| i as u8) + } + + fn descending() -> [u8; 32] { + std::array::from_fn(|i| 0xFF - i as u8) + } + + /// The keys DIP-13 pins for these inputs (BIP-39 `abandon x11 about`, + /// empty passphrase); a change orphans every key already registered + /// on-chain. The public key is the compressed point of the returned + /// scalar. #[test] fn connect_keypairs_match_the_pinned_vectors() { use dpp::secp256k1::{PublicKey as SecpPublicKey, SecretKey}; + use ApplicationKeyPurpose::{Decryption, Encryption}; + + let rotated = |identity: [u8; 32], contract, purpose, rotation| ConnectKey::AppEncryption { + identity_id: Identifier::from(identity), + contract_id: contract, + purpose, + rotation, + }; + let legacy = |identity: [u8; 32], contract, purpose| ConnectKey::AppEncryptionLegacy { + identity_id: Identifier::from(identity), + contract_id: contract, + purpose, + }; + let (asc, desc) = (ascending(), descending()); for (network, key, expected) in [ ( @@ -754,14 +916,52 @@ mod tests { ), ( Network::Mainnet, - encryption_key(ApplicationKeyPurpose::Encryption), + rotated(CONNECT_IDENTITY, CONNECT_LEAF, Encryption, 0), + "031a882c119e2b1f8e0349b444d877afa2599efeef49a26930fd25518cb6e9d57a", + ), + ( + Network::Mainnet, + rotated(CONNECT_IDENTITY, CONNECT_LEAF, Encryption, 1), + "03d0f6f0f48d968b9554d8e7fe467d7135bacf29d5c4994ca593bd10ebeed696f6", + ), + ( + Network::Mainnet, + rotated(asc, desc, Encryption, 0), + "02dfed57ddaaea8c0758e8da44513520038eb8d6b0b92109953c14d6ab1087259b", + ), + ( + Network::Mainnet, + rotated(asc, desc, Decryption, 0), + "020bcde800f41ad1f924477a9502f111445224cc9ab70114ae4e5099ba02bdd034", + ), + ( + Network::Mainnet, + rotated(asc, desc, Decryption, 1), + "021b97869c21933673076a938337c28848c8bf8094bbe6883e3a4acbf33c639072", + ), + ( + Network::Mainnet, + legacy(CONNECT_IDENTITY, CONNECT_LEAF, Encryption), "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434", ), + ( + Network::Mainnet, + legacy(asc, desc, Encryption), + "03c4e462229f176595ec17e28e9096d3cfbc42496fb8ed4d48f8f58355d6105aa9", + ), + ( + Network::Mainnet, + legacy(asc, desc, Decryption), + "031fd0a3cfc01bebb89d43c2e12a6321c37ebdacb62c09495899e5b65a24fa5c99", + ), ] { let derived = derive_connect_keypair_from_master(&master_for(network), network, key) .expect("connect derive"); - assert_eq!(hex::encode(derived.public_key), expected); - assert_eq!(derived.derivation_path, key.derivation_path(network)); + assert_eq!(hex::encode(derived.public_key), expected, "{key:?}"); + assert_eq!( + derived.derivation_path, + key.derivation_path(network).expect("connect path") + ); let secret: [u8; 32] = *derived.private_key; let sk = SecretKey::from_secret_bytes(secret).expect("valid scalar"); @@ -772,20 +972,128 @@ mod tests { } } - /// The two halves of an encryption pair and the session key are distinct. + /// The rotated path is the legacy path plus one hardened `rotation'` + /// level: nine levels against eight. + #[test] + fn app_encryption_path_appends_a_hardened_rotation_to_the_legacy_path() { + let legacy = legacy_encryption_key(ApplicationKeyPurpose::Decryption) + .derivation_path(Network::Testnet) + .expect("legacy path"); + let rotated = encryption_key(ApplicationKeyPurpose::Decryption, 7) + .derivation_path(Network::Testnet) + .expect("rotated path"); + + assert_eq!(legacy.len(), 8); + assert_eq!(rotated.len(), 9); + assert_eq!(rotated[..8], legacy[..]); + assert_eq!(rotated[8], ChildNumber::Hardened { index: 7 }); + } + + /// A rotation is a 31-bit hardened index; anything wider is refused + /// rather than wrapped onto another key. #[test] - fn connect_keys_are_distinct_per_kind_and_purpose() { + fn app_encryption_rotation_must_fit_a_hardened_index() { + let key = |rotation| encryption_key(ApplicationKeyPurpose::Encryption, rotation); + let master = master_for(Network::Testnet); + + assert!( + derive_connect_keypair_from_master(&master, Network::Testnet, key(0x7FFF_FFFF)).is_ok() + ); + for rotation in [0x8000_0000, u32::MAX] { + let err = derive_connect_keypair_from_master(&master, Network::Testnet, key(rotation)) + .err() + .expect("rotation >= 2^31 must be refused"); + assert!( + matches!(err, PlatformWalletError::InvalidParameter(_)), + "{err:?}" + ); + } + } + + /// The two halves of an encryption pair, each rotation, the legacy + /// layout and the session key are all distinct. + #[test] + fn connect_keys_are_distinct_per_kind_purpose_and_rotation() { let master = master_for(Network::Testnet); let derive = |key| { derive_connect_keypair_from_master(&master, Network::Testnet, key) .expect("connect derive") .public_key }; - let session = derive(session_key()); - let encryption = derive(encryption_key(ApplicationKeyPurpose::Encryption)); - let decryption = derive(encryption_key(ApplicationKeyPurpose::Decryption)); - assert_ne!(session, encryption); - assert_ne!(session, decryption); - assert_ne!(encryption, decryption); + let all = [ + derive(session_key()), + derive(encryption_key(ApplicationKeyPurpose::Encryption, 0)), + derive(encryption_key(ApplicationKeyPurpose::Decryption, 0)), + derive(encryption_key(ApplicationKeyPurpose::Encryption, 1)), + derive(legacy_encryption_key(ApplicationKeyPurpose::Encryption)), + ]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "connect keys collided (index {i})"); + } + } + } + + /// The raw-level constructors accept exactly the DIP-13 combinations + /// and build the matching variant. + #[test] + fn connect_key_from_parts_builds_the_valid_combinations() { + let identity_id = Identifier::from(CONNECT_IDENTITY); + assert_eq!( + ConnectKey::from_parts(6, identity_id, CONNECT_LEAF, 0, 0).expect("session"), + session_key() + ); + assert_eq!( + ConnectKey::from_parts(7, identity_id, CONNECT_LEAF, 1, 0).expect("encryption"), + encryption_key(ApplicationKeyPurpose::Encryption, 0) + ); + assert_eq!( + ConnectKey::from_parts(7, identity_id, CONNECT_LEAF, 2, 0x7FFF_FFFF) + .expect("decryption at the top rotation"), + encryption_key(ApplicationKeyPurpose::Decryption, 0x7FFF_FFFF) + ); + assert_eq!( + ConnectKey::legacy_app_encryption_from_parts(identity_id, CONNECT_LEAF, 1) + .expect("legacy encryption"), + legacy_encryption_key(ApplicationKeyPurpose::Encryption) + ); + assert_eq!( + ConnectKey::legacy_app_encryption_from_parts(identity_id, CONNECT_LEAF, 2) + .expect("legacy decryption"), + legacy_encryption_key(ApplicationKeyPurpose::Decryption) + ); + } + + #[test] + fn connect_key_from_parts_refuses_other_combinations() { + let identity_id = Identifier::from(CONNECT_IDENTITY); + for (sub_feature, purpose, rotation, expected) in [ + (7, 3, 0, "purpose 3"), + (7, 0, 0, "purpose 0"), + (7, 1, 0x8000_0000, "rotation 2147483648"), + (7, 2, u32::MAX, "rotation 4294967295"), + (6, 1, 0, "sub_feature 6 with purpose 1 and rotation 0"), + (6, 0, 1, "sub_feature 6 with purpose 0 and rotation 1"), + (5, 0, 0, "sub_feature 5 with purpose 0 and rotation 0"), + ] { + let err = + ConnectKey::from_parts(sub_feature, identity_id, CONNECT_LEAF, purpose, rotation) + .expect_err("invalid combination must be refused"); + match err { + PlatformWalletError::InvalidParameter(message) => { + assert!(message.contains(expected), "{message}") + } + other => panic!("expected InvalidParameter, got {other:?}"), + } + } + for purpose in [0, 3] { + let err = + ConnectKey::legacy_app_encryption_from_parts(identity_id, CONNECT_LEAF, purpose) + .expect_err("invalid legacy purpose must be refused"); + assert!( + matches!(&err, PlatformWalletError::InvalidParameter(m) if m.contains(&format!("purpose {purpose}"))), + "{err:?}" + ); + } } } diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 5602c73c255..f7249a3118d 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -38,7 +38,7 @@ use jni::sys::{jboolean, jbyteArray, jint, jlong, jobject}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; use platform_wallet_ffi::derive_connect_key::ConnectDerivedKeyFFI; -use platform_wallet_ffi::error::platform_wallet_ffi_result_free; +use platform_wallet_ffi::error::{platform_wallet_ffi_result_free, PlatformWalletFFIResult}; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_discovery::DiscoveredIdentityIdsFFI; use platform_wallet_ffi::identity_key_preview::{IdentityKeyPreviewFFI, IdentityKeyPreviewsFFI}; @@ -484,15 +484,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive } /// DashPay Connect key derivation at the DIP-13 sub-feature paths -/// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`, +/// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose'/rotation']`, /// dashpay/dips#191). Thin marshaler over -/// `dash_sdk_derive_connect_key_with_resolver`: resolver-keyed and pure +/// `dash_sdk_derive_connect_key_v2_with_resolver`: resolver-keyed and pure /// like its slot-derive siblings, so it is safe from persistence-callback /// context. Returns `[privateKey: byte[32], publicKey: byte[33]]`; the /// Rust-side key is zeroized before return and Kotlin owns the copy. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_deriveConnectKeyWithResolver( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_deriveConnectKeyV2WithResolver( mut env: JNIEnv, _class: JClass, network_ord: jint, @@ -502,17 +502,23 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive identity_id: JByteArray, leaf: JByteArray, purpose: jint, + rotation: jint, ) -> jni::sys::jobjectArray { guard(&mut env, ptr::null_mut(), |env| { if sub_feature < 0 { throw_sdk_exception(env, 1, "subFeature must be non-negative"); return ptr::null_mut(); } - // The FFI refuses any combination other than (6, 0), (7, 1), (7, 2). + // The FFI refuses any combination other than (6, 0, 0) and + // (7, 1 or 2, any 31-bit rotation). if purpose < 0 { throw_sdk_exception(env, 1, "purpose must be non-negative"); return ptr::null_mut(); } + if rotation < 0 { + throw_sdk_exception(env, 1, "rotation must be non-negative"); + return ptr::null_mut(); + } if resolver_handle == 0 { throw_sdk_exception(env, 1, "resolverHandle must be non-zero"); return ptr::null_mut(); @@ -529,7 +535,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive let mut out = ConnectDerivedKeyFFI::empty(); let result = unsafe { - platform_wallet_ffi::dash_sdk_derive_connect_key_with_resolver( + platform_wallet_ffi::dash_sdk_derive_connect_key_v2_with_resolver( net_from_ord(network_ord), wid.as_ptr(), resolver_handle as *mut MnemonicResolverHandle, @@ -537,42 +543,107 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_derive identity.as_ptr(), leaf.as_ptr(), purpose as u32, + rotation as u32, &mut out as *mut ConnectDerivedKeyFFI, ) }; - if take_pwffi_error(env, result) { - unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; + connect_key_to_java(env, result, &mut out) + }) +} + +/// Pre-rotation DashPay Connect app encryption key +/// (`m/9'/coin'/5'/7'/0'/identityId'/contractId'/purpose'`), for recognising +/// keys registered on testnet/devnet before DIP-13 gained the `rotation'` +/// level. Thin marshaler over +/// `dash_sdk_derive_legacy_app_encryption_key_with_resolver`; returns +/// `[privateKey: byte[32], publicKey: byte[33]]` like +/// `deriveConnectKeyV2WithResolver`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_deriveLegacyAppEncryptionKeyWithResolver( + mut env: JNIEnv, + _class: JClass, + network_ord: jint, + wallet_id: JByteArray, + resolver_handle: jlong, + identity_id: JByteArray, + contract_id: JByteArray, + purpose: jint, +) -> jni::sys::jobjectArray { + guard(&mut env, ptr::null_mut(), |env| { + // The FFI refuses any purpose other than 1 or 2. + if purpose < 0 { + throw_sdk_exception(env, 1, "purpose must be non-negative"); return ptr::null_mut(); } - - // Copy both halves before the free wipes the inline buffers; the - // private copy is zeroized on drop. - let scalar = zeroize::Zeroizing::new(out.private_key_bytes); - let pubkey = out.public_key_bytes; - unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(&mut out) }; - - let Ok(priv_arr) = env.byte_array_from_slice(&*scalar) else { - let _ = env.exception_clear(); - throw_sdk_exception(env, 99, "connect key marshalling failed"); + if resolver_handle == 0 { + throw_sdk_exception(env, 1, "resolverHandle must be non-zero"); + return ptr::null_mut(); + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { return ptr::null_mut(); }; - let build = (|| -> Result { - let pub_arr = env.byte_array_from_slice(&pubkey)?; - let byte_array_class = env.find_class("[B")?; - let result = - env.new_object_array(2, byte_array_class, jni::objects::JObject::null())?; - env.set_object_array_element(&result, 0, &priv_arr)?; - env.set_object_array_element(&result, 1, pub_arr)?; - Ok(result.into_raw()) - })(); - build.unwrap_or_else(|_| { - let _ = env.exception_clear(); - // `Zeroizing` only wipes the Rust copy; overwrite the JVM copy of - // the private key too, since it never reaches Kotlin's cleanup. - let _ = env.set_byte_array_region(&priv_arr, 0, &[0i8; 32]); - throw_sdk_exception(env, 99, "connect key marshalling failed"); - ptr::null_mut() - }) + let Some(identity) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let Some(contract) = read_id32(env, &contract_id, "contractId") else { + return ptr::null_mut(); + }; + + let mut out = ConnectDerivedKeyFFI::empty(); + let result = unsafe { + platform_wallet_ffi::dash_sdk_derive_legacy_app_encryption_key_with_resolver( + net_from_ord(network_ord), + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + identity.as_ptr(), + contract.as_ptr(), + purpose as u32, + &mut out as *mut ConnectDerivedKeyFFI, + ) + }; + connect_key_to_java(env, result, &mut out) + }) +} + +/// Turn a connect-key FFI result into `[privateKey, publicKey]`, or throw +/// its error. Wipes `out` in place either way (by reference, so no copy of +/// the scalar is left behind). +fn connect_key_to_java( + env: &mut JNIEnv, + result: PlatformWalletFFIResult, + out: &mut ConnectDerivedKeyFFI, +) -> jni::sys::jobjectArray { + if take_pwffi_error(env, result) { + unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(out) }; + return ptr::null_mut(); + } + + // Copy both halves before the free wipes the inline buffers; the + // private copy is zeroized on drop. + let scalar = zeroize::Zeroizing::new(out.private_key_bytes); + let pubkey = out.public_key_bytes; + unsafe { platform_wallet_ffi::dash_sdk_derive_connect_key_free(out) }; + + let Ok(priv_arr) = env.byte_array_from_slice(&*scalar) else { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "connect key marshalling failed"); + return ptr::null_mut(); + }; + let build = (|| -> Result { + let pub_arr = env.byte_array_from_slice(&pubkey)?; + let byte_array_class = env.find_class("[B")?; + let result = env.new_object_array(2, byte_array_class, jni::objects::JObject::null())?; + env.set_object_array_element(&result, 0, &priv_arr)?; + env.set_object_array_element(&result, 1, pub_arr)?; + Ok(result.into_raw()) + })(); + build.unwrap_or_else(|_| { + let _ = env.exception_clear(); + // `Zeroizing` only wipes the Rust copy; overwrite the JVM copy of + // the private key too, since it never reaches Kotlin's cleanup. + let _ = env.set_byte_array_region(&priv_arr, 0, &[0i8; 32]); + throw_sdk_exception(env, 99, "connect key marshalling failed"); + ptr::null_mut() }) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 548e2d19c83..f89ffd57881 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1527,7 +1527,8 @@ extension ManagedPlatformWallet { } /// A DashPay Connect key derived at a DIP-13 sub-feature path (see - /// `deriveConnectKey(subFeature:identityId:leaf:purpose:)`). + /// `deriveConnectKey(subFeature:identityId:leaf:network:storage:)` and + /// `deriveConnectKey(subFeature:identityId:leaf:purpose:rotation:network:storage:)`). public struct ConnectDerivedKey: Sendable, Equatable { /// 33-byte compressed secp256k1 public key. public let publicKeyData: Data @@ -1542,7 +1543,7 @@ extension ManagedPlatformWallet { } /// DIP-13 sub-features DashPay Connect v2 keys live under - /// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose']`). + /// (`m/9'/coin'/5'/'/0'/identityId'/leaf'[/purpose'/rotation']`). /// Registered by the DIP-13 amendment dashpay/dips#191. public enum ConnectSubFeature: Sendable { /// Session authentication key: the leaf is the connect request id @@ -1550,7 +1551,8 @@ extension ManagedPlatformWallet { case sessionAuthentication /// App encryption key pair: the leaf is the id of the data /// contract the key is bound to; the purpose level is - /// `ConnectKeyPurpose.encryption` or `.decryption`. + /// `ConnectKeyPurpose.encryption` or `.decryption`, followed by a + /// `rotation'` level. case appEncryption /// The DIP-13 sub-feature index the FFI derives under. @@ -1562,7 +1564,7 @@ extension ManagedPlatformWallet { } } - /// The trailing `purpose'` level of the app-encryption path: the DPP + /// The `purpose'` level of the app-encryption path: the DPP /// purpose discriminant of the half being derived. Only these two /// exist in the DIP-13 amendment, so the type makes any other value /// (in particular `0`, which would collide with the FFI's "no purpose @@ -1579,7 +1581,7 @@ extension ManagedPlatformWallet { } } - /// The trailing `purpose'` level the FFI appends. + /// The `purpose'` level the FFI derives under. var ffiValue: UInt32 { switch self { case .encryption: return UInt32(CONNECT_KEY_PURPOSE_ENCRYPTION) @@ -1588,23 +1590,23 @@ extension ManagedPlatformWallet { } } - /// Derive a DashPay Connect key at - /// `m/9'/coin'/5'/'/0'/'/'[/']` - /// from this wallet's seed, resolved on demand through the - /// `MnemonicResolver` (the mnemonic never lives in a Swift `String` - /// outside the resolver trampoline; see `deriveIdentityAuthKeyAtSlot`). + /// Derive the DashPay Connect session authentication key at + /// `m/9'/coin'/5'/6'/0'/'/'` from this wallet's seed, + /// resolved on demand through the `MnemonicResolver` (the mnemonic never + /// lives in a Swift `String` outside the resolver trampoline; see + /// `deriveIdentityAuthKeyAtSlot`). /// - /// `identityId` and `leaf` are DIP-14 256-bit hardened children, so - /// nothing wallet-local is an input and two devices restored from one - /// seed derive the same key. `.appEncryption` requires a `purpose`, - /// which picks the half of its pair; `.sessionAuthentication` requires - /// `nil`. Any other combination throws `invalidParameter`. + /// `identityId` and `leaf` (the connect request id) are DIP-14 256-bit + /// hardened children, so nothing wallet-local is an input and two + /// devices restored from one seed derive the same key. Only + /// `.sessionAuthentication` derives here: app encryption keys need a + /// purpose and rotation, so `.appEncryption` throws `invalidParameter`; + /// use `deriveConnectKey(subFeature:identityId:leaf:purpose:rotation:network:storage:)`. /// /// - Parameters: - /// - subFeature: `.sessionAuthentication` or `.appEncryption`. + /// - subFeature: `.sessionAuthentication`. /// - identityId: the identity's 32-byte id. - /// - leaf: the 32-byte request id or bound contract id. - /// - purpose: the half of an encryption pair to derive, or `nil`. + /// - leaf: the 32-byte connect request id. /// - network: the wallet's network, which selects the coin type. /// - storage: defaults to a fresh `WalletStorage()`; overridable /// for tests. Used by the resolver vtable. @@ -1613,9 +1615,109 @@ extension ManagedPlatformWallet { subFeature: ConnectSubFeature, identityId: Identifier, leaf: Data, - purpose: ConnectKeyPurpose? = nil, network: Network, storage: WalletStorage = WalletStorage() + ) throws -> ConnectDerivedKey { + try deriveConnectKeyThroughFFI( + identityId: identityId, leaf: leaf, leafName: "leaf", storage: storage + ) { walletBytes, resolver, identityBytes, leafBytes, out in + dash_sdk_derive_connect_key_v2_with_resolver( + network.ffiValue, walletBytes, resolver, subFeature.ffiValue, + identityBytes, leafBytes, 0, 0, out + ) + } + } + + /// Derive one half of a DashPay Connect app encryption pair at + /// `m/9'/coin'/5'/7'/0'/'/'/'/'` + /// from this wallet's seed, resolved on demand like + /// `deriveConnectKey(subFeature:identityId:leaf:network:storage:)`. + /// + /// `rotation` is required: `0` is the identity's first pair for the + /// contract and each later rotation a fresh pair, up to `2^31 - 1` + /// (larger values throw `invalidParameter`). Keys registered before + /// DIP-13 gained the rotation level come from + /// `deriveLegacyAppEncryptionKey(identityId:contractId:purpose:network:storage:)`. + /// Only `.appEncryption` derives here; `.sessionAuthentication` throws + /// `invalidParameter`. + /// + /// - Parameters: + /// - subFeature: `.appEncryption`. + /// - identityId: the identity's 32-byte id. + /// - leaf: the 32-byte id of the contract the key is bound to. + /// - purpose: the half of the pair to derive. + /// - rotation: the `rotation'` level, `0` first. + /// - network: the wallet's network, which selects the coin type. + /// - storage: defaults to a fresh `WalletStorage()`; overridable + /// for tests. Used by the resolver vtable. + @MainActor + public func deriveConnectKey( + subFeature: ConnectSubFeature, + identityId: Identifier, + leaf: Data, + purpose: ConnectKeyPurpose, + rotation: UInt32, + network: Network, + storage: WalletStorage = WalletStorage() + ) throws -> ConnectDerivedKey { + try deriveConnectKeyThroughFFI( + identityId: identityId, leaf: leaf, leafName: "leaf", storage: storage + ) { walletBytes, resolver, identityBytes, leafBytes, out in + dash_sdk_derive_connect_key_v2_with_resolver( + network.ffiValue, walletBytes, resolver, subFeature.ffiValue, + identityBytes, leafBytes, purpose.ffiValue, rotation, out + ) + } + } + + /// Derive one half of a DashPay Connect app encryption pair at the + /// pre-rotation layout + /// `m/9'/coin'/5'/7'/0'/'/'/'`, which + /// has no `rotation'` level. Only for recognising keys registered on + /// testnet/devnet before DIP-13 gained that level; derive new keys with + /// `deriveConnectKey(subFeature:identityId:leaf:purpose:rotation:network:storage:)`. + /// + /// - Parameters: + /// - identityId: the identity's 32-byte id. + /// - contractId: the 32-byte id of the contract the key is bound to. + /// - purpose: the half of the pair to derive. + /// - network: the wallet's network, which selects the coin type. + /// - storage: defaults to a fresh `WalletStorage()`; overridable + /// for tests. Used by the resolver vtable. + @MainActor + public func deriveLegacyAppEncryptionKey( + identityId: Identifier, + contractId: Data, + purpose: ConnectKeyPurpose, + network: Network, + storage: WalletStorage = WalletStorage() + ) throws -> ConnectDerivedKey { + try deriveConnectKeyThroughFFI( + identityId: identityId, leaf: contractId, leafName: "contractId", storage: storage + ) { walletBytes, resolver, identityBytes, contractBytes, out in + dash_sdk_derive_legacy_app_encryption_key_with_resolver( + network.ffiValue, walletBytes, resolver, identityBytes, + contractBytes, purpose.ffiValue, out + ) + } + } + + /// Shared marshalling for the connect-key derives: validates the id + /// lengths, pins the resolver across the call, and copies the key out + /// before the deferred free zeroizes it. + @MainActor + private func deriveConnectKeyThroughFFI( + identityId: Identifier, + leaf: Data, + leafName: String, + storage: WalletStorage, + derive: ( + UnsafePointer, + UnsafeMutablePointer?, + UnsafePointer, + UnsafePointer, + UnsafeMutablePointer + ) -> PlatformWalletFFIResult ) throws -> ConnectDerivedKey { guard self.walletId.count == 32 else { throw PlatformWalletError.invalidParameter( @@ -1629,7 +1731,7 @@ extension ManagedPlatformWallet { } guard leaf.count == 32 else { throw PlatformWalletError.invalidParameter( - "leaf must be 32 bytes, got \(leaf.count)" + "\(leafName) must be 32 bytes, got \(leaf.count)" ) } let resolver = MnemonicResolver(storage: storage) @@ -1644,14 +1746,11 @@ extension ManagedPlatformWallet { self.walletId.withUnsafeBytes { walletBytes -> PlatformWalletFFIResult in identityId.withUnsafeBytes { identityBytes -> PlatformWalletFFIResult in leaf.withUnsafeBytes { leafBytes -> PlatformWalletFFIResult in - dash_sdk_derive_connect_key_with_resolver( - network.ffiValue, + derive( walletBytes.bindMemory(to: UInt8.self).baseAddress!, resolver.handle, - subFeature.ffiValue, identityBytes.bindMemory(to: UInt8.self).baseAddress!, leafBytes.bindMemory(to: UInt8.self).baseAddress!, - purpose?.ffiValue ?? 0, &out ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift index 74948c87997..fa586e40592 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ConnectKeyDerivationTests.swift @@ -3,11 +3,11 @@ import XCTest @testable import SwiftDashSDK /// DashPay Connect key derivation at the DIP-13 sub-feature paths -/// (`m/9'/coin'/5'/{6|7}'/0'/identityId'/leaf'[/purpose']`, dashpay/dips#191) -/// through `ManagedPlatformWallet.deriveConnectKey`. The fixed vectors are -/// the ones `platform-wallet`'s -/// `connect_keypair_is_deterministic_from_fixed_vectors` pins on the Rust -/// side, so a drift on either side of the FFI is caught. +/// (`m/9'/coin'/5'/{6|7}'/0'/identityId'/leaf'[/purpose'/rotation']`, +/// dashpay/dips#191) through `ManagedPlatformWallet.deriveConnectKey` and +/// `deriveLegacyAppEncryptionKey`. The fixed vectors are the ones +/// `platform-wallet`'s `connect_keypairs_match_the_pinned_vectors` pins on +/// the Rust side, so a drift on either side of the FFI is caught. @MainActor final class ConnectKeyDerivationTests: XCTestCase { @@ -21,8 +21,17 @@ final class ConnectKeyDerivationTests: XCTestCase { /// Pinned in `identity_handle.rs`: testnet, sub-feature 6', no purpose. private let testnetAuthPubkeyHex = "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d" - /// Pinned in `identity_handle.rs`: mainnet, sub-feature 7', purpose 1'. + /// Pinned in `identity_handle.rs`: mainnet, sub-feature 7', purpose 1', + /// rotation 0'. private let mainnetEncryptionPubkeyHex = + "031a882c119e2b1f8e0349b444d877afa2599efeef49a26930fd25518cb6e9d57a" + /// Pinned in `identity_handle.rs`: mainnet, sub-feature 7', purpose 1', + /// rotation 1'. + private let mainnetEncryptionRotationOnePubkeyHex = + "03d0f6f0f48d968b9554d8e7fe467d7135bacf29d5c4994ca593bd10ebeed696f6" + /// Pinned in `identity_handle.rs`: mainnet, sub-feature 7', purpose 1', + /// pre-rotation layout (no rotation level). + private let mainnetLegacyEncryptionPubkeyHex = "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434" /// In-memory `WalletStorage` so the resolver never touches the macOS @@ -83,8 +92,19 @@ final class ConnectKeyDerivationTests: XCTestCase { let encryption = try wallet.deriveConnectKey( subFeature: .appEncryption, identityId: identityId, leaf: leaf, - purpose: .encryption, network: .mainnet, storage: storage) + purpose: .encryption, rotation: 0, network: .mainnet, storage: storage) XCTAssertEqual(encryption.publicKeyData.toHexString(), mainnetEncryptionPubkeyHex) + + let nextRotation = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, rotation: 1, network: .mainnet, storage: storage) + XCTAssertEqual( + nextRotation.publicKeyData.toHexString(), mainnetEncryptionRotationOnePubkeyHex) + + let legacy = try wallet.deriveLegacyAppEncryptionKey( + identityId: identityId, contractId: leaf, purpose: .encryption, + network: .mainnet, storage: storage) + XCTAssertEqual(legacy.publicKeyData.toHexString(), mainnetLegacyEncryptionPubkeyHex) } func testDifferentLeavesPurposesAndSubFeaturesGiveDifferentKeys() throws { @@ -101,19 +121,54 @@ final class ConnectKeyDerivationTests: XCTestCase { leaf: leaf, network: .testnet, storage: storage) let enc = try wallet.deriveConnectKey( subFeature: .appEncryption, identityId: identityId, leaf: leaf, - purpose: .encryption, network: .testnet, storage: storage) + purpose: .encryption, rotation: 0, network: .testnet, storage: storage) let dec = try wallet.deriveConnectKey( subFeature: .appEncryption, identityId: identityId, leaf: leaf, - purpose: .decryption, network: .testnet, storage: storage) + purpose: .decryption, rotation: 0, network: .testnet, storage: storage) + let encNextRotation = try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, rotation: 1, network: .testnet, storage: storage) + let encLegacy = try wallet.deriveLegacyAppEncryptionKey( + identityId: identityId, contractId: leaf, purpose: .encryption, + network: .testnet, storage: storage) let mainnetAuth = try wallet.deriveConnectKey( subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, network: .mainnet, storage: storage) - let all = [auth, authOtherLeaf, authOtherIdentity, enc, dec, mainnetAuth] + let all = [ + auth, authOtherLeaf, authOtherIdentity, enc, dec, encNextRotation, encLegacy, + mainnetAuth, + ] .map { $0.publicKeyData } XCTAssertEqual(Set(all).count, all.count, "every variation derives a distinct key") } + func testRejectsMismatchedSubFeaturesAndAWideRotation() throws { + let (wallet, storage) = try makeWallet() + + // App encryption needs a purpose and rotation; the session key has + // neither. + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + network: .testnet, storage: storage)) + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .sessionAuthentication, identityId: identityId, leaf: leaf, + purpose: .encryption, rotation: 0, network: .testnet, storage: storage)) + // A rotation is a 31-bit hardened index. + XCTAssertThrowsError( + try wallet.deriveConnectKey( + subFeature: .appEncryption, identityId: identityId, leaf: leaf, + purpose: .encryption, rotation: 0x8000_0000, network: .testnet, + storage: storage) + ) { error in + guard case PlatformWalletError.invalidParameter = error else { + return XCTFail("expected invalidParameter, got \(error)") + } + } + } + func testRejectsMalformedIdsAndAMissingMnemonic() throws { let (wallet, storage) = try makeWallet()