From 160d35fa2ddfdd38d274fe079569776dbe13764a Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 22 Sep 2026 19:54:44 -0500 Subject: [PATCH] feat(key-wallet): DIP-13 application session authentication and encryption paths Adds sub-features 6' (application session authentication) and 7' (application encryption) under m/9'/coin_type'/5'/ from the DIP-13 amendment in dashpay/dips#191: the sub-feature constants and mainnet / testnet roots in dip9.rs, two DerivationPathReference variants (declared after Root because the bincode derive encodes variants by position), and DerivationPath::application_session_authentication_path / application_encryption_path in bip32.rs, with ApplicationKeyPurpose next to KeyDerivationType for the trailing key purpose level. The identity, request and contract ids are DIP-14 256-bit hardened children, which only secp256k1 derivation defines, so the builders take no key type and always put ECDSA (0') at the key type level. A BLS key cannot be requested on these paths. Tests pin the path strings on both networks and the derived public keys for the all-zero-entropy mnemonic, with the uniform ids dashpay/platform already pins (so moving the derivation here moves no key) and with non-uniform ids so a byte-order or argument-order change fails. Co-Authored-By: Claude Opus 5.5 (1M context) --- key-wallet/src/bip32.rs | 224 ++++++++++++++++++++++++++++++++++++++-- key-wallet/src/dip9.rs | 87 ++++++++++++++++ 2 files changed, 304 insertions(+), 7 deletions(-) diff --git a/key-wallet/src/bip32.rs b/key-wallet/src/bip32.rs index bb9914cf3..96df27adf 100644 --- a/key-wallet/src/bip32.rs +++ b/key-wallet/src/bip32.rs @@ -33,13 +33,15 @@ use secp256k1::{self, XOnlyPublicKey}; use serde; use crate::dip9::{ - ASSET_LOCK_ADDRESS_TOPUP_PATH_MAINNET, ASSET_LOCK_ADDRESS_TOPUP_PATH_TESTNET, - ASSET_LOCK_SHIELDED_ADDRESS_TOPUP_PATH_MAINNET, ASSET_LOCK_SHIELDED_ADDRESS_TOPUP_PATH_TESTNET, - COINJOIN_PATH_MAINNET, COINJOIN_PATH_TESTNET, DASH_BIP44_PATH_MAINNET, DASH_BIP44_PATH_TESTNET, - IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, - IDENTITY_INVITATION_PATH_MAINNET, IDENTITY_INVITATION_PATH_TESTNET, - IDENTITY_REGISTRATION_PATH_MAINNET, IDENTITY_REGISTRATION_PATH_TESTNET, - IDENTITY_TOPUP_PATH_MAINNET, IDENTITY_TOPUP_PATH_TESTNET, + APPLICATION_ENCRYPTION_PATH_MAINNET, APPLICATION_ENCRYPTION_PATH_TESTNET, + APPLICATION_SESSION_AUTHENTICATION_PATH_MAINNET, + APPLICATION_SESSION_AUTHENTICATION_PATH_TESTNET, ASSET_LOCK_ADDRESS_TOPUP_PATH_MAINNET, + ASSET_LOCK_ADDRESS_TOPUP_PATH_TESTNET, ASSET_LOCK_SHIELDED_ADDRESS_TOPUP_PATH_MAINNET, + ASSET_LOCK_SHIELDED_ADDRESS_TOPUP_PATH_TESTNET, COINJOIN_PATH_MAINNET, COINJOIN_PATH_TESTNET, + DASH_BIP44_PATH_MAINNET, DASH_BIP44_PATH_TESTNET, IDENTITY_AUTHENTICATION_PATH_MAINNET, + IDENTITY_AUTHENTICATION_PATH_TESTNET, IDENTITY_INVITATION_PATH_MAINNET, + IDENTITY_INVITATION_PATH_TESTNET, IDENTITY_REGISTRATION_PATH_MAINNET, + IDENTITY_REGISTRATION_PATH_TESTNET, IDENTITY_TOPUP_PATH_MAINNET, IDENTITY_TOPUP_PATH_TESTNET, }; use base58ck; #[cfg(feature = "bincode")] @@ -1025,6 +1027,24 @@ impl From for u32 { } } +/// The `key_purpose'` level of a DIP-13 application encryption path: the Platform identity key +/// purpose the derived key is registered with. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[repr(u32)] +pub enum ApplicationKeyPurpose { + Encryption = 1, + Decryption = 2, +} + +impl From for u32 { + fn from(val: ApplicationKeyPurpose) -> Self { + match val { + ApplicationKeyPurpose::Encryption => 1, + ApplicationKeyPurpose::Decryption => 2, + } + } +} + impl DerivationPath { pub fn bip_44_account(network: Network, account: u32) -> Self { let mut root_derivation_path: DerivationPath = match network { @@ -1176,6 +1196,66 @@ impl DerivationPath { root_derivation_path } + /// DIP-13 application session authentication key path, + /// `m/9'/coin_type'/5'/6'/0'/identity_id'/request_id'`. The identity id and request id are + /// DIP-14 256-bit hardened children, which only secp256k1 derivation defines, so the key type + /// level is always ECDSA (`0'`). + pub fn application_session_authentication_path( + network: Network, + identity_id: [u8; 32], + request_id: [u8; 32], + ) -> Self { + let mut root_derivation_path: DerivationPath = match network { + Network::Mainnet => APPLICATION_SESSION_AUTHENTICATION_PATH_MAINNET, + _ => APPLICATION_SESSION_AUTHENTICATION_PATH_TESTNET, + } + .into(); + root_derivation_path.0.extend(&[ + ChildNumber::Hardened { + index: KeyDerivationType::ECDSA.into(), + }, + ChildNumber::Hardened256 { + index: identity_id, + }, + ChildNumber::Hardened256 { + index: request_id, + }, + ]); + root_derivation_path + } + + /// DIP-13 application encryption key path, + /// `m/9'/coin_type'/5'/7'/0'/identity_id'/contract_id'/key_purpose'`. The identity id and + /// contract id are DIP-14 256-bit hardened children, which only secp256k1 derivation defines, + /// so the key type level is always ECDSA (`0'`). + pub fn application_encryption_path( + network: Network, + identity_id: [u8; 32], + contract_id: [u8; 32], + key_purpose: ApplicationKeyPurpose, + ) -> Self { + let mut root_derivation_path: DerivationPath = match network { + Network::Mainnet => APPLICATION_ENCRYPTION_PATH_MAINNET, + _ => APPLICATION_ENCRYPTION_PATH_TESTNET, + } + .into(); + root_derivation_path.0.extend(&[ + ChildNumber::Hardened { + index: KeyDerivationType::ECDSA.into(), + }, + ChildNumber::Hardened256 { + index: identity_id, + }, + ChildNumber::Hardened256 { + index: contract_id, + }, + ChildNumber::Hardened { + index: key_purpose.into(), + }, + ]); + root_derivation_path + } + pub fn derive_priv_ecdsa_for_master_seed( &self, seed: &[u8], @@ -2676,6 +2756,136 @@ mod tests { assert_eq!(path.to_string(), "m/9'/1'/5'/0'/1'/2'/3'"); } + #[test] + fn test_application_session_authentication_path() { + let path = DerivationPath::application_session_authentication_path( + Network::Mainnet, + [0x01; 32], + [0x02; 32], + ); + assert_eq!( + path.to_string(), + format!("m/9'/5'/5'/6'/0'/0x{}'/0x{}'", "01".repeat(32), "02".repeat(32)) + ); + + let path = DerivationPath::application_session_authentication_path( + Network::Testnet, + [0x01; 32], + [0x02; 32], + ); + assert_eq!( + path.to_string(), + format!("m/9'/1'/5'/6'/0'/0x{}'/0x{}'", "01".repeat(32), "02".repeat(32)) + ); + } + + #[test] + fn test_application_encryption_path() { + let path = DerivationPath::application_encryption_path( + Network::Mainnet, + [0x01; 32], + [0x02; 32], + ApplicationKeyPurpose::Encryption, + ); + assert_eq!( + path.to_string(), + format!("m/9'/5'/5'/7'/0'/0x{}'/0x{}'/1'", "01".repeat(32), "02".repeat(32)) + ); + + let path = DerivationPath::application_encryption_path( + Network::Testnet, + [0x01; 32], + [0x02; 32], + ApplicationKeyPurpose::Decryption, + ); + assert_eq!( + path.to_string(), + format!("m/9'/1'/5'/7'/0'/0x{}'/0x{}'/2'", "01".repeat(32), "02".repeat(32)) + ); + } + + /// Pinned public keys for the all-zero-entropy test mnemonic. A change here orphans registered + /// encryption keys, which wallets re-derive from seed. The uniform ids match the vectors + /// dashpay/platform pins; the non-uniform ones catch a byte-order or argument-order change. + #[test] + fn test_application_key_vectors() { + let seed = crate::mnemonic::Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap() + .to_seed(""); + let ascending: [u8; 32] = core::array::from_fn(|i| i as u8); + let descending: [u8; 32] = core::array::from_fn(|i| 0xff - i as u8); + let public_key_hex = |path: DerivationPath, network| { + hex::encode( + path.derive_pub_ecdsa_for_master_seed(&seed, network) + .unwrap() + .public_key + .serialize(), + ) + }; + + let session = |identity_id, request_id| { + public_key_hex( + DerivationPath::application_session_authentication_path( + Network::Testnet, + identity_id, + request_id, + ), + Network::Testnet, + ) + }; + let encryption = |identity_id, contract_id, key_purpose| { + public_key_hex( + DerivationPath::application_encryption_path( + Network::Mainnet, + identity_id, + contract_id, + key_purpose, + ), + Network::Mainnet, + ) + }; + + assert_eq!( + session([0x35; 32], [0x6B; 32]), + "022c8b2e806244482374b1caf8306146dc03aad3b99a5954efd5e70a0eddd37a5d" + ); + assert_eq!( + encryption([0x35; 32], [0x6B; 32], ApplicationKeyPurpose::Encryption), + "03e989de1b62f137231cc06659c810c17100becd1f5e23d5710faa7602769fe434" + ); + assert_eq!( + session(ascending, descending), + "0278ad78c1a220924bf0e06e1111c61422efeace068f30efa825604b436970b230" + ); + assert_eq!( + encryption(ascending, descending, ApplicationKeyPurpose::Encryption), + "03c4e462229f176595ec17e28e9096d3cfbc42496fb8ed4d48f8f58355d6105aa9" + ); + assert_eq!( + encryption(ascending, descending, ApplicationKeyPurpose::Decryption), + "031fd0a3cfc01bebb89d43c2e12a6321c37ebdacb62c09495899e5b65a24fa5c99" + ); + + // The builder's path is the documented string, byte order included. + let parsed: DerivationPath = format!( + "m/9'/1'/5'/6'/0'/0x{}'/0x{}'", + hex::encode(ascending), + hex::encode(descending) + ) + .parse() + .unwrap(); + assert_eq!( + parsed, + DerivationPath::application_session_authentication_path( + Network::Testnet, + ascending, + descending, + ) + ); + } + #[test] fn test_derive_priv_ecdsa_for_master_seed() { let path = DerivationPath::bip_44_account(Network::Mainnet, 0); diff --git a/key-wallet/src/dip9.rs b/key-wallet/src/dip9.rs index eaf9493d6..1f2683dd9 100644 --- a/key-wallet/src/dip9.rs +++ b/key-wallet/src/dip9.rs @@ -30,6 +30,9 @@ pub enum DerivationPathReference { BlockchainAssetLockAddressTopupFunding = 17, BlockchainAssetLockShieldedAddressTopupFunding = 18, Root = 255, + // Declared after `Root` so the bincode variant index of every earlier variant is unchanged. + ApplicationSessionAuthentication = 19, + ApplicationEncryption = 20, } bitflags! { @@ -131,6 +134,12 @@ pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_TOPUP: u32 = 2; pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_INVITATIONS: u32 = 3; pub const FEATURE_PURPOSE_ASSET_LOCK_SUBFEATURE_ADDRESS_TOPUP: u32 = 4; pub const FEATURE_PURPOSE_ASSET_LOCK_SUBFEATURE_SHIELDED_ADDRESS_TOPUP: u32 = 5; +/// DIP-13 application session authentication sub-feature: +/// `m/9'/coin_type'/5'/6'/key_type'/identity_id'/request_id'`. +pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION: u32 = 6; +/// DIP-13 application encryption sub-feature: +/// `m/9'/coin_type'/5'/7'/key_type'/identity_id'/contract_id'/key_purpose'`. +pub const FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION: u32 = 7; pub const FEATURE_PURPOSE_DASHPAY: u32 = 15; /// DIP-15 auto-accept feature index: the derivation family /// `m/9'/coin_type'/16'/expiry'` holding the shareable, expiry-bounded @@ -473,6 +482,84 @@ pub const IDENTITY_AUTHENTICATION_PATH_TESTNET: IndexConstPath<4> = IndexConstPa path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, }; +// Application Session Authentication Keys Paths +pub const APPLICATION_SESSION_AUTHENTICATION_PATH_MAINNET: IndexConstPath<4> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION, + }, + ], + reference: DerivationPathReference::ApplicationSessionAuthentication, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +pub const APPLICATION_SESSION_AUTHENTICATION_PATH_TESTNET: IndexConstPath<4> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_TESTNET_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_SESSION_AUTHENTICATION, + }, + ], + reference: DerivationPathReference::ApplicationSessionAuthentication, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +// Application Encryption Keys Paths +pub const APPLICATION_ENCRYPTION_PATH_MAINNET: IndexConstPath<4> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION, + }, + ], + reference: DerivationPathReference::ApplicationEncryption, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +pub const APPLICATION_ENCRYPTION_PATH_TESTNET: IndexConstPath<4> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_TESTNET_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_APPLICATION_ENCRYPTION, + }, + ], + reference: DerivationPathReference::ApplicationEncryption, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + // DIP-17: Platform Payment Address Paths // Path: m/9'/coin_type'/17'/account'/key_class'/index // Note: The full path includes account'/key_class'/index which is appended during derivation