diff --git a/CHANGELOG.md b/CHANGELOG.md index f992e099f..5fb32ec6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `AccountMetadataKey` +- `DerivationTool.deriveAccountMetadataKey` +- `DerivationTool.derivePrivateUseMetadataKey` - `Synchronizer.getTransactionsByMemoSubstring()` has been added - `Synchronizer.redactPcztForSigner` - `Synchronizer.pcztRequiresSaplingProofs` diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Derivation.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Derivation.kt index fa03d4957..cb90ce02a 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Derivation.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/Derivation.kt @@ -1,5 +1,6 @@ package cash.z.ecc.android.sdk.internal +import cash.z.ecc.android.sdk.internal.model.JniMetadataKey import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey interface Derivation { @@ -38,6 +39,44 @@ interface Derivation { numberOfAccounts: Int ): Array + /** + * Derives a ZIP 325 Account Metadata Key from the given seed. + * + * @return an account metadata key. + */ + fun deriveAccountMetadataKey( + seed: ByteArray, + networkId: Int, + accountIndex: Long + ): JniMetadataKey + + /** + * Derives a metadata key for private use from a ZIP 325 Account Metadata Key. + * + * If `ufvk` is non-null, this method will return one metadata key for every FVK item + * contained within the UFVK, in preference order. As UFVKs may in general change over + * time (due to the inclusion of new higher-preference FVK items, or removal of older + * deprecated FVK items), private usage of these keys should always follow preference + * order: + * - For encryption-like private usage, the first key in the array should always be + * used, and all other keys ignored. + * - For decryption-like private usage, each key in the array should be tried in turn + * until metadata can be recovered, and then the metadata should be re-encrypted + * under the first key. + * + * @param ufvk the external UFVK for which a metadata key is required, or `null` if the + * metadata key is "inherent" (for the same account as the Account Metadata Key). + * @param privateUseSubject a globally unique non-empty sequence of at most 252 bytes that + * identifies the desired private-use context. + * @return an array of 32-byte metadata keys in preference order. + */ + fun derivePrivateUseMetadataKey( + accountMetadataKey: JniMetadataKey, + ufvk: String?, + networkId: Int, + privateUseSubject: ByteArray + ): Array + /** * Derives a ZIP 32 Arbitrary Key from the given seed at the "wallet level", i.e. * directly from the seed with no ZIP 32 path applied. diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt index ff8cea065..1158c5827 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/JniConstants.kt @@ -12,3 +12,13 @@ const val JNI_ACCOUNT_UUID_BYTES_SIZE = 16 * The number of bytes in the seed fingerprint parameter. It's used e.g. in [JniAccount.seedFingerprint] */ const val JNI_ACCOUNT_SEED_FP_BYTES_SIZE = 32 + +/** + * The number of bytes in an HD-derived ZIP 32 key. It's used e.g. in [JniMetadataKey.sk] + */ +const val JNI_METADATA_KEY_SK_SIZE = 32 + +/** + * The number of bytes in a chain code. It's used e.g. in [JniMetadataKey.chainCode] + */ +const val JNI_METADATA_KEY_CHAIN_CODE_SIZE = 32 diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustDerivationTool.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustDerivationTool.kt index 792c9ee19..df874b85c 100644 --- a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustDerivationTool.kt +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/jni/RustDerivationTool.kt @@ -1,6 +1,7 @@ package cash.z.ecc.android.sdk.internal.jni import cash.z.ecc.android.sdk.internal.Derivation +import cash.z.ecc.android.sdk.internal.model.JniMetadataKey import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey class RustDerivationTool private constructor() : Derivation { @@ -40,6 +41,26 @@ class RustDerivationTool private constructor() : Derivation { networkId: Int ): String = deriveUnifiedAddressFromViewingKey(viewingKey, networkId = networkId) + override fun deriveAccountMetadataKey( + seed: ByteArray, + networkId: Int, + accountIndex: Long + ): JniMetadataKey = deriveAccountMetadataKeyFromSeed(seed, accountIndex, networkId) + + override fun derivePrivateUseMetadataKey( + accountMetadataKey: JniMetadataKey, + ufvk: String?, + networkId: Int, + privateUseSubject: ByteArray + ): Array = + derivePrivateUseMetadataKey( + accountMetadataKey_sk = accountMetadataKey.sk, + accountMetadataKey_c = accountMetadataKey.chainCode, + ufvk, + privateUseSubject, + networkId + ) + override fun deriveArbitraryWalletKey( contextString: ByteArray, seed: ByteArray @@ -98,6 +119,22 @@ class RustDerivationTool private constructor() : Derivation { networkId: Int ): String + private external fun deriveAccountMetadataKeyFromSeed( + seed: ByteArray, + accountIndex: Long, + networkId: Int + ): JniMetadataKey + + @Suppress("FunctionParameterNaming") + @JvmStatic + private external fun derivePrivateUseMetadataKey( + accountMetadataKey_sk: ByteArray, + accountMetadataKey_c: ByteArray, + ufvk: String?, + privateUseSubject: ByteArray, + networkId: Int + ): Array + @JvmStatic private external fun deriveArbitraryWalletKeyFromSeed( contextString: ByteArray, diff --git a/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/JniMetadataKey.kt b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/JniMetadataKey.kt new file mode 100644 index 000000000..b8422ed60 --- /dev/null +++ b/backend-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/JniMetadataKey.kt @@ -0,0 +1,29 @@ +package cash.z.ecc.android.sdk.internal.model + +import androidx.annotation.Keep +import cash.z.ecc.android.sdk.internal.jni.JNI_METADATA_KEY_CHAIN_CODE_SIZE +import cash.z.ecc.android.sdk.internal.jni.JNI_METADATA_KEY_SK_SIZE + +/** + * Serves as cross layer (Kotlin, Rust) communication class. + * + * @param sk the ZIP 32 key required to derive child keys. + * @param chainCode The ZIP 32 chain code required to derive child keys. + * + * @throws IllegalArgumentException if the values are inconsistent. + */ +@Keep +class JniMetadataKey( + val sk: ByteArray, + val chainCode: ByteArray, +) { + init { + require(sk.size == JNI_METADATA_KEY_SK_SIZE) { + "Account UUID must be 32 bytes" + } + + require(chainCode.size == JNI_METADATA_KEY_CHAIN_CODE_SIZE) { + "Seed fingerprint must be 32 bytes" + } + } +} diff --git a/backend-lib/src/main/rust/lib.rs b/backend-lib/src/main/rust/lib.rs index 0a8dfeef2..b3e97ab8d 100644 --- a/backend-lib/src/main/rust/lib.rs +++ b/backend-lib/src/main/rust/lib.rs @@ -26,7 +26,8 @@ use tracing_subscriber::reload; use transparent::bundle::{OutPoint, TxOut}; use utils::{java_nullable_string_to_rust, java_string_to_rust}; use uuid::Uuid; -use zcash_address::{ToAddress, ZcashAddress}; +use zcash_address::unified::{Container, Encoding, Item as _}; +use zcash_address::{unified, ToAddress, ZcashAddress}; use zcash_client_backend::data_api::{ AccountPurpose, BirthdayError, TransactionDataRequest, TransactionStatus, Zip32Derivation, }; @@ -78,7 +79,9 @@ use zcash_protocol::{ value::{ZatBalance, Zatoshis}, ShieldedProtocol, }; -use zip32::{fingerprint::SeedFingerprint, ChildIndex, DiversifierIndex}; +use zip32::{ + fingerprint::SeedFingerprint, registered::PathElement, ChainCode, ChildIndex, DiversifierIndex, +}; use crate::utils::{catch_unwind, exception::unwrap_exc_or}; @@ -2368,6 +2371,138 @@ pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustDerivationTool_de unwrap_exc_or(&mut env, res, ptr::null_mut()) } +fn encode_metadata_key<'a>( + env: &mut JNIEnv<'a>, + key: zip32::registered::SecretKey, +) -> anyhow::Result> { + Ok(env.new_object( + "cash/z/ecc/android/sdk/internal/model/JniMetadataKey", + "([B[B)V", + &[ + (&env.byte_array_from_slice(key.data())?).into(), + (&env.byte_array_from_slice(key.chain_code().as_bytes())?).into(), + ], + )?) +} + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustDerivationTool_deriveAccountMetadataKeyFromSeed< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + seed: JByteArray<'local>, + account_index: jlong, + network_id: jint, +) -> jobject { + let res = catch_unwind(&mut env, |env| { + let _span = + tracing::info_span!("RustDerivationTool.deriveAccountMetadataKeyFromSeed").entered(); + let network = parse_network(network_id as u32)?; + let seed = secret_from_jni(env, seed)?; + let account = zip32_account_index_from_jlong(account_index)?; + + let key = zip32::registered::SecretKey::from_subpath( + b"MetadataKeys", + seed.expose_secret(), + // TODO: Change this to whatever ZIP number is assigned to the metadata key ZIP draft. + 325, + &[ + PathElement::new(ChildIndex::hardened(network.coin_type()), &[]), + PathElement::new(ChildIndex::hardened(account.into()), &[]), + ], + )?; + + Ok(encode_metadata_key(env, key)?.into_raw()) + }); + unwrap_exc_or(&mut env, res, ptr::null_mut()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustDerivationTool_derivePrivateUseMetadataKey< + 'local, +>( + mut env: JNIEnv<'local>, + _: JClass<'local>, + account_metadata_key_sk: JByteArray<'local>, + account_metadata_key_c: JByteArray<'local>, + ufvk_string: JString<'local>, + private_use_subject: JByteArray<'local>, + network_id: jint, +) -> jobjectArray { + let res = catch_unwind(&mut env, |env| { + let _span = tracing::info_span!("RustDerivationTool.derivePrivateUseMetadataKey").entered(); + let account_metadata_key_sk = utils::java_bytes_to_rust(env, &account_metadata_key_sk)?; + let account_metadata_key_c = utils::java_bytes_to_rust(env, &account_metadata_key_c)?; + let ufvk_string = utils::java_nullable_string_to_rust(env, &ufvk_string)?; + let private_use_subject = utils::java_bytes_to_rust(env, &private_use_subject)?; + let network = parse_network(network_id as u32)?; + + let account_metadata_key = { + let sk = account_metadata_key_sk + .as_slice() + .try_into() + .map_err(|_| anyhow!("Incorrect length for account_metadata_key_sk"))?; + + let chain_code = ChainCode::new( + account_metadata_key_c + .as_slice() + .try_into() + .map_err(|_| anyhow!("Incorrect length for account_metadata_key_c"))?, + ); + + zip32::registered::SecretKey::from_parts(sk, chain_code) + }; + + let private_use_keys = match ufvk_string { + // For the inherent subtree, there is only ever one key. + None => vec![account_metadata_key + .derive_child_with_tag(ChildIndex::hardened(0), &[]) + .derive_child_with_tag(ChildIndex::PRIVATE_USE, &private_use_subject)], + // For the external subtree, we derive keys from the UFVK's items. + Some(ufvk_string) => { + let (net, ufvk) = + unified::Ufvk::decode(&ufvk_string).map_err(|e| anyhow!("{e}"))?; + let expected_net = network.network_type(); + if net != expected_net { + return Err(anyhow!( + "UFVK is for network {:?} but we expected {:?}", + net, + expected_net, + )); + } + + // Any metadata should always be associated with the key derived from the + // most preferred FVK item. However, we don't know which FVK items the + // UFVK contained the last time we were asked to derive keys. So we derive + // every key and return them to the caller in preference order. If the + // caller finds data associated with an older FVK item, they will migrate + // it to the first key we return. + ufvk.items() + .into_iter() + .map(|fvk_item| { + account_metadata_key + .derive_child_with_tag(ChildIndex::hardened(1), &[]) + .derive_child_with_tag( + ChildIndex::hardened(0), + &fvk_item.typed_encoding(), + ) + .derive_child_with_tag(ChildIndex::PRIVATE_USE, &private_use_subject) + }) + .collect() + } + }; + + Ok( + utils::rust_vec_to_java(env, private_use_keys, "[B", |env, key| { + utils::rust_bytes_to_java(env, key.data()) + })? + .into_raw(), + ) + }); + unwrap_exc_or(&mut env, res, ptr::null_mut()) +} + #[unsafe(no_mangle)] pub extern "C" fn Java_cash_z_ecc_android_sdk_internal_jni_RustDerivationTool_deriveArbitraryWalletKeyFromSeed< 'local, diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/DerivationToolExt.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/DerivationToolExt.kt index 21d0f7450..2c6a86a67 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/DerivationToolExt.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/DerivationToolExt.kt @@ -1,6 +1,8 @@ package cash.z.ecc.android.sdk.internal +import cash.z.ecc.android.sdk.internal.model.JniMetadataKey import cash.z.ecc.android.sdk.internal.model.JniUnifiedSpendingKey +import cash.z.ecc.android.sdk.model.AccountMetadataKey import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.ZcashNetwork @@ -52,6 +54,19 @@ fun Derivation.deriveUnifiedFullViewingKeysTypesafe( numberOfAccounts ).map { UnifiedFullViewingKey(it) } +fun Derivation.deriveAccountMetadataKeyTypesafe( + seed: ByteArray, + network: ZcashNetwork, + accountIndex: Zip32AccountIndex +): JniMetadataKey = deriveAccountMetadataKey(seed, network.id, accountIndex.index) + +fun Derivation.derivePrivateUseMetadataKeyTypesafe( + accountMetadataKey: AccountMetadataKey, + ufvk: String?, + network: ZcashNetwork, + privateUseSubject: ByteArray +): Array = derivePrivateUseMetadataKey(accountMetadataKey.toUnsafe(), ufvk, network.id, privateUseSubject) + fun Derivation.deriveArbitraryWalletKeyTypesafe( contextString: ByteArray, seed: ByteArray diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeDerivationToolImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeDerivationToolImpl.kt index 1e7b97726..2a89bf8df 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeDerivationToolImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/TypesafeDerivationToolImpl.kt @@ -1,5 +1,6 @@ package cash.z.ecc.android.sdk.internal +import cash.z.ecc.android.sdk.model.AccountMetadataKey import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.ZcashNetwork @@ -35,6 +36,25 @@ internal class TypesafeDerivationToolImpl(private val derivation: Derivation) : network: ZcashNetwork, ): String = derivation.deriveUnifiedAddress(viewingKey, network) + override suspend fun deriveAccountMetadataKey( + seed: ByteArray, + network: ZcashNetwork, + accountIndex: Zip32AccountIndex + ): AccountMetadataKey = AccountMetadataKey(derivation.deriveAccountMetadataKeyTypesafe(seed, network, accountIndex)) + + override suspend fun derivePrivateUseMetadataKey( + accountMetadataKey: AccountMetadataKey, + ufvk: String?, + network: ZcashNetwork, + privateUseSubject: ByteArray + ): Array = + derivation.derivePrivateUseMetadataKeyTypesafe( + accountMetadataKey, + ufvk, + network, + privateUseSubject + ) + override suspend fun deriveArbitraryWalletKey( contextString: ByteArray, seed: ByteArray diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/AccountMetadataKey.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/AccountMetadataKey.kt new file mode 100644 index 000000000..8854bde16 --- /dev/null +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/model/AccountMetadataKey.kt @@ -0,0 +1,62 @@ +package cash.z.ecc.android.sdk.model + +import cash.z.ecc.android.sdk.internal.model.JniMetadataKey +import cash.z.ecc.android.sdk.tool.DerivationTool + +/** + * A [ZIP 325](https://zips.z.cash/zip-0325) Account Metadata Key. + */ +class AccountMetadataKey private constructor( + private val sk: FirstClassByteArray, + private val chainCode: FirstClassByteArray +) { + internal constructor(jniMetadataKey: JniMetadataKey) : this( + FirstClassByteArray(jniMetadataKey.sk.copyOf()), + FirstClassByteArray(jniMetadataKey.chainCode.copyOf()) + ) + + // Override to prevent leaking key to logs + override fun toString() = "AccountMetadataKey(bytes=***)" + + /** + * Derives a metadata key for private use from this ZIP 325 Account Metadata Key. + * + * If `ufvk` is non-null, this method will return one metadata key for every FVK item + * contained within the UFVK, in preference order. As UFVKs may in general change over + * time (due to the inclusion of new higher-preference FVK items, or removal of older + * deprecated FVK items), private usage of these keys should always follow preference + * order: + * - For encryption-like private usage, the first key in the array should always be + * used, and all other keys ignored. + * - For decryption-like private usage, each key in the array should be tried in turn + * until metadata can be recovered, and then the metadata should be re-encrypted + * under the first key. + * + * @param ufvk the external UFVK for which a metadata key is required, or `null` if the + * metadata key is "inherent" (for the same account as the Account Metadata Key). + * @param privateUseSubject a globally unique non-empty sequence of at most 252 bytes that + * identifies the desired private-use context. + * @return an array of 32-byte metadata keys in preference order. + */ + suspend fun derivePrivateUseMetadataKey( + ufvk: String?, + network: ZcashNetwork, + privateUseSubject: ByteArray + ): Array = + // TODO [#1685]: I don't want DerivationTool.derivePrivateUseMetadataKey in the + // public API, but the way DerivationTool is constructed, I don't see how to expose + // this only to AccountMetadataKey. + DerivationTool.getInstance().derivePrivateUseMetadataKey( + this, + ufvk, + network, + privateUseSubject + ) + + /** + * Exposes the type-unsafe variant for passing across the JNI. + */ + fun toUnsafe(): JniMetadataKey { + return JniMetadataKey(sk.byteArray, chainCode.byteArray) + } +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/tool/DerivationTool.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/tool/DerivationTool.kt index eb164a3db..d5d513442 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/tool/DerivationTool.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/tool/DerivationTool.kt @@ -4,6 +4,7 @@ import cash.z.ecc.android.sdk.internal.Derivation import cash.z.ecc.android.sdk.internal.SuspendingLazy import cash.z.ecc.android.sdk.internal.TypesafeDerivationToolImpl import cash.z.ecc.android.sdk.internal.jni.RustDerivationTool +import cash.z.ecc.android.sdk.model.AccountMetadataKey import cash.z.ecc.android.sdk.model.UnifiedFullViewingKey import cash.z.ecc.android.sdk.model.UnifiedSpendingKey import cash.z.ecc.android.sdk.model.ZcashNetwork @@ -81,6 +82,44 @@ interface DerivationTool { network: ZcashNetwork ): String + /** + * Derives a ZIP 325 Account Metadata Key from the given seed. + * + * @return an account metadata key. + */ + suspend fun deriveAccountMetadataKey( + seed: ByteArray, + network: ZcashNetwork, + accountIndex: Zip32AccountIndex + ): AccountMetadataKey + + /** + * Derives a metadata key for private use from a ZIP 325 Account Metadata Key. + * + * If `ufvk` is non-null, this method will return one metadata key for every FVK item + * contained within the UFVK, in preference order. As UFVKs may in general change over + * time (due to the inclusion of new higher-preference FVK items, or removal of older + * deprecated FVK items), private usage of these keys should always follow preference + * order: + * - For encryption-like private usage, the first key in the array should always be + * used, and all other keys ignored. + * - For decryption-like private usage, each key in the array should be tried in turn + * until metadata can be recovered, and then the metadata should be re-encrypted + * under the first key. + * + * @param ufvk the external UFVK for which a metadata key is required, or `null` if the + * metadata key is "inherent" (for the same account as the Account Metadata Key). + * @param privateUseSubject a globally unique non-empty sequence of at most 252 bytes that + * identifies the desired private-use context. + * @return an array of 32-byte metadata keys in preference order. + */ + suspend fun derivePrivateUseMetadataKey( + accountMetadataKey: AccountMetadataKey, + ufvk: String?, + network: ZcashNetwork, + privateUseSubject: ByteArray + ): Array + /** * Derives a [ZIP 32 Arbitrary Key] from the given seed at the "wallet level", i.e. * directly from the seed with no ZIP 32 path applied.