Grant Application - MFKDF2

Hi @hanh,

Yes – passkey as a factor is available. This is the setup code in the original MFKDF repo (which does have previously mentioned security updates) and this is it in the newer Rust repo.

As a note, for passkeys to work with MFKDF2, you have to have the PRF extension available from your authenticator. In the web, you’d use the navigator.credentials API. Specifically, navigator.credentials.create() for the publicKey type where you’d set options such as PublicKeyCredentialCreationOptions with extensions containing the PRF extension enabled. Note: not all authenticators support this extension! We have found through our own testing that older versions of MacOS and the current Windows authenticator do not seem to support PRF. Please let me know if you need further examples here. There are more docs now merged into the Rust repo that you may find helpful!

The library is built for key derivation broadly, whether you want to use this for authentication or encryption from there is up to you. As for recovery, this is also part of the available API, however, if the policy is lost entirely, then you will lose the ability to derive that original key even with the factors you have available to you. The policy itself contains extra data necessary for deriving the key. Policies can be self-signed and stored publicly so that you may verify their integrity if you restore a policy from a public location at a later point. We recommend keeping your policy backed up in many places. Again, the material of a policy is safe to store publicly, so you can use your favorite hosting service as one storage option.

Please let me know if this is lacking in any way. I’m always happy to help work on this with you!

1 Like

Hmmm … Yes, it seems to work how I understood. However, this brings a big design problem that I hope you can help me solve since it is a deal breaker at this moment.

I want to realize this vision you mentioned in the proposal:

MFKDF2 has the potential of making novice Zcash users more comfortable with moving to self-custody by eliminating the need for seed phrases and other cumbersome manual key management strategies, allowing those users to experience the full privacy benefits of Zcash for the first time.

(emphasis mine)

My use case is as follows:

The user Alice wants to create a new account in her wallet app to send and receive funds. The wallet app must let her choose a password and generate a TOTP QR code. Alice scans the QR code on her phone Google Authenticator and registers a new TOTP.
Then, the wallet app creates an account.
One day, the phone breaks. She has the password and the TOTP authenticator.
The wallet app MUST be able to recover her funds on a new phone.

To be in line with “eliminating the need for seed phrases and other cumbersome manual key management strategies”, the wallet app is not allowed to require her to backup a seed phrase or any other data.

This is where I am stuck. I have the policy data but it looks like it needs to be backed up by the user. I looked at it and it’s a shamir shared secret, split from a random key. Therefore I don’t see how it can be recovered from the password and TOTP exclusively. How would you design the wallet app to fill these requirements?

Thanks

2 Likes

I see what you’re saying here. Let me break this down a bit and at least provide a perspective on this that may be helpful. As always feedback is appreciated!

Consider the case of a seed phrase style wallet (BIP32/39, etc.). A user must back up this seed phrase directly and you must also know how to derive a private key from the seed phrase itself as well. Think of MFKDF2 as being closer to “how to derive a private key from the seed phrase” rather than a user remembering storing a traditional seed phrase.

In this case, the wallet application is in charge of storing the policy information that Alice needs to properly derive her key. Given she can tell the wallet application “I am Alice” (i.e., via a username like alice123) she can be given her policy and input her password and TOTP code from the authenticator. This places minimal trust on the wallet application. That is, the wallet application solely has to host the policy and be available upon Alice’s request.

If Alice is extra cautious, she can also store a backup of the policy herself so that she doesn’t need to rely on any third party whatsoever. Likewise, as claimed in the original paper, Alice’s policy could even be stored on a public blockchain or other public storage medium (e.g., IPFS) so that she has redundancy.


If I, personally, were designing the wallet application, I would store the policy on behalf of Alice and provide this to her as needed. Though not necessary, defense in depth can be added by requiring Alice provide some subset of standardized authentication factors to retrieve her custom policy so that a malicious user, Bob, would have to know these factors to even retrieve the policy before brute-forcing Alice’s true private key. Likewise, I would offer Alice the ability to store a copy of her policy noting that for specific factors (such as TOTP) the policy will need updated over time.

Alice can easily store the policy in the cloud as she wishes too.

I am sorry but this seems that you are moving the goal posts. First of all, knowing the derivation path is the least of the worries since it is documented by ZIP-32.
Second, the grant submission was rather clear in its wording. I quote:

MFKDF2 has the potential of making novice Zcash users more comfortable with moving to self-custody by eliminating the need for seed phrases and other cumbersome manual key management strategies, allowing those users to experience the full privacy benefits of Zcash for the first time.

This is quite exciting and is the cornerstone of the grant. I don’t think you can change that promise.


Your suggestion is to store the users policy?

If I, personally, were designing the wallet application, I would store the policy on behalf of Alice and provide this to her as needed.

I would be extremely uncomfortable with this solution since it gives secret key material to a third party. Even if it is a share, it raises the issue of trust and IMO, such design does not qualify as self custody anymore.

Edit: Besides, if the solution is to split the secret and store in the cloud, it could be done with the seed phrase.

Could you think of another solution?

Thanks,
–h

Hi @hanh,

I appreciate you bringing up these concerns. It’s important to nail this for users to have proper security and enhanced UX at the same time. Let me reiterate a few points:

MFKDF does, indeed, remove the need for seed phrases and manual key management strategies. However, as with many other methods, there is still a need for something akin to a salt to be stored somewhere. The policy for a specific MFKDF does need to be known by the party who is deriving the key.

The secret material you are referencing is the MFKDF policy which can be safely given to a third party if a user wishes since this policy information is either encrypted or public metadata. The only way to compromise the encrypted information in the policy is by deriving the MFKDF key itself (this is what is proven in the original paper via the “entropy” result).

Happy to answer any more questions or address any concerns you may have, as always.

1 Like

Well, this statement contradicts itself. You begin by saying it removes the need for manual key management and then you say there is something that must be stored somewhere.

Mfkdf replaces the 24 word seed phrase with a multi kb policy. It is not clear to me where the win is in this scenario.

Maybe you can suggest another use case?

MFKDF replaces the need for a user to safely store secret information (a task which is a challenge for many users) with a task that the user and/or a 3rd party can do without risk of secret information being lost.

You are correct that without the policy available that the key cannot be derived. This is also true for a seed phrase, but a seed phrase itself must also be safe guarded.

The policy file should be safe guarded too, shouldn’t it?

@hanh Vivek here! There are no confidentiality requirements for the policy file. It’s designed to be stored in an untrusted cloud, or openly on IPFS or on a blockchain. Note that integrity/availability is still important, because losing the policy file could cause a loss of access to the wallet. Hope that helps! :slight_smile:

I think your intuition is valid but nuanced. My understanding is the policy could contain information useful to a malicious actor but only under certain circumstances. However, in most circumstances publishing the policy is generally acceptable, provided the nuances are well understood by the developer.

To reconcile this, I found the Reconstitution Example helpful. It suggests that while publishing the policy doesn’t directly compromise the keys, weak policies can weaken the barrier to key derivation. Effectively, a known policy with weak parameters might reduce the search space.

This creates a specific challenge with policy updates. Since the library allows the policy to change, your derived key is effectively only as secure as the weakest policy you have ever used.

Given this, I suspect that in a wallet context, allowing user driven policy updates introduces risks that may be better managed by simply rotating to a new key and policy. Clear documentation on these trade-offs will be essential for developers to adopt this safely.

P.S. These are just my observations as an outsider; I defer to the Multifactor team to correct any technical misunderstandings.

2 Likes

Huge supporter for this! UX improvements are key for bringing privacy to everyone, and from what I know the multifactor team is absolutely cracked.

2 Likes

I deliberated for a long time whether to include a MFKDF2 derived key based on password + totp as an alternate method without seed phrase. Finally, I think that the cons outweigh the pros, though it is a matter of opinion.

Pros

  • don’t need to save a 24 word seed phrase
  • can use any password
  • 2fa with totp provides additional security

Cons

  • the policy file becomes critical. Losing it means losing the key.
  • If the policy file is public (blockchain, shared drive),
    • Depending on the type of factors, the key can be brute forced. A typical totp key requires 1m attempts and could be broken in a few minutes. The security of a password can be high, but many passwords are very weak: 123456, password, etc. The wallet essentially becomes a brain wallet.
  • If the policy file is kept secret, it becomes a key management issue that we tried to avoid.

The user makes decisions that can greatly affect the security of their account. For instance, if they choose a simple password and puts the policy file in a public blockchain, I am afraid their funds are going to be easily stolen. Seed phrases have the same entropy as long as the users use a correct crypto RNG.

TLDR; I think with mfkdf2, some users will misuse the system, get their funds stolen and blame it on the app.

1 Like

Thanks for you feedback @hanh. I agree that TOTP + passwords are not a usecase I’d encourage any wallet to allow.

My intention when approving this grant was not to encourage users to choose a combination of weak factors. Rather it is to allow users to become comfortable using incredibly strong and convenient factors like Google or Apple passkeys while allowing them to strengthen those with additional or alternative factors to fit within their own risk models and comfort levels for both security and redundancy. I’d love to see a Zcash wallet explore this use case.

3 Likes

Hi ZCash Community,

At long last, we are excited to announce the conclusion of Milestones 2 and 3 of the MFKDF2 project! We experienced several unanticipated difficulties during the completion of these milestones, which unfortunately caused us to fall behind schedule, but work on MFKDF2 is continuing.

Milestone 2 - MFKDF2 Client Implementation

Milestone 3 - Red-Team Evaluation

  • We comissioned the creation of a red-team report by Matteo Scarlata, PhD candidate at ETH Zurich and author of the original MFKDF cryptanalysis paper. Matteo’s initial report on MFKDF2 is attached here:

    report.pdf (214.8 KB)

  • Several issues were discovered by Matteo in the first version of his report that required additional time and effort to fix. After several rounds of revision, we are confident that most of the addressable issues have been mitigated. We will prepare an updated report documenting the fixes along with our next milestone.

We are excited to proceed with Milestone 4 (Final Publication & Reporting), where we will provide enhanced technical documentation of our journey and ultimate solution, and look forward to keeping the Zcash community in the loop throughout this process. Cheers!

2 Likes

Thank you for your update. In order to better evaluate your deliverables, could you point or provide an example of usage for the following scenario:

  • 1/3 factors key
  • passphrase (stored in system or cloud storage)
  • totp
  • recovery code
  • the solution should be secure to serve as a seed phrase
2 Likes

Absolutely! Here’s an example of how to set up a key using a passphrase, a TOTP code, and a recovery code (formatted as a UUID for this example), using the requested “1/3” setup (i.e., any one of the three factors can be used to derive the key):

use std::collections::HashMap;

use mfkdf2::definitions::{MFKDF2Options, factor::MFKDF2Factor};
use mfkdf2::setup::factors::{
    password::PasswordOptions,
    totp::TOTPOptions,
    uuid::{UUIDOptions, Uuid},
};

// Fixed recovery code UUID — in a real app this would be generated at setup
// and shown to the user once to store securely.
const RECOVERY_CODE: &str = "550e8400-e29b-41d4-a716-446655440000";

fn setup_factors() -> Result<Vec<MFKDF2Factor>, mfkdf2::error::MFKDF2Error> {
    Ok(vec![
        mfkdf2::setup::factors::password(
            "correct horse battery staple",
            PasswordOptions { id: Some("passphrase".to_string()) },
        )?,
        mfkdf2::setup::factors::totp(TOTPOptions {
            id:     Some("totp".to_string()),
            secret: Some(b"abcdefghijklmnopqrst".to_vec()),
            time:   Some(1),
            ..Default::default()
        })?,
        mfkdf2::setup::factors::uuid::uuid(UUIDOptions {
            id:   Some("recovery_code".to_string()),
            uuid: Some(Uuid::parse_str(RECOVERY_CODE).unwrap()),
        })?,
    ])
}

fn main() -> Result<(), mfkdf2::error::MFKDF2Error> {
    // Setup: register all 3 factors with threshold=1 (any one factor is sufficient)
    let setup = mfkdf2::setup::key(
        &setup_factors()?,
        MFKDF2Options { threshold: Some(1), ..MFKDF2Options::default() },
    )?;

    let setup_hex: String = setup.key.iter().map(|b| format!("{:02x}", b)).collect();
    println!("Setup   key: {}", setup_hex);
}

From here, here’s the code you would use to later re-derive the key using just the password:

    // Derive with passphrase only
    let from_passphrase = mfkdf2::derive::key(
        &setup.policy,
        HashMap::from([(
            "passphrase".to_string(),
            mfkdf2::derive::factors::password("correct horse battery staple")?,
        )]),
        true,
        false,
    )?;
    let passphrase_hex: String = from_passphrase.key.iter().map(|b| format!("{:02x}", b)).collect();
    println!("Passphrase:  {} (match: {})", passphrase_hex, passphrase_hex == setup_hex);

Continuing, here’s the code to re-derive the key with just the TOTP code:

    // Derive with TOTP only
    let from_totp = mfkdf2::derive::key(
        &setup.policy,
        HashMap::from([(
            "totp".to_string(),
            mfkdf2::derive::factors::totp(
                241063,
                Some(mfkdf2::derive::factors::totp::TOTPDeriveOptions {
                    time: Some(30001),
                    ..Default::default()
                }),
            )?,
        )]),
        true,
        false,
    )?;
    let totp_hex: String = from_totp.key.iter().map(|b| format!("{:02x}", b)).collect();
    println!("TOTP:        {} (match: {})", totp_hex, totp_hex == setup_hex);

And finally, here’s the code to derive from just the recovery code:

    // Derive with recovery code only
    let from_recovery = mfkdf2::derive::key(
        &setup.policy,
        HashMap::from([(
            "recovery_code".to_string(),
            mfkdf2::derive::factors::uuid(Uuid::parse_str(RECOVERY_CODE).unwrap())?,
        )]),
        true,
        false,
    )?;
    let recovery_hex: String = from_recovery.key.iter().map(|b| format!("{:02x}", b)).collect();
    println!("Recovery:    {} (match: {})", recovery_hex, recovery_hex == setup_hex);

Now, if we run the code, we can get the same key all four times:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
     Running `target\debug\zcash_example.exe`
Setup   key: b556ef0c4bd9f94876798efa97b867861d8a6a7610d02e201713eaf220ed63bc
Passphrase:  b556ef0c4bd9f94876798efa97b867861d8a6a7610d02e201713eaf220ed63bc (match: true)
TOTP:        b556ef0c4bd9f94876798efa97b867861d8a6a7610d02e201713eaf220ed63bc (match: true)
Recovery:    b556ef0c4bd9f94876798efa97b867861d8a6a7610d02e201713eaf220ed63bc (match: true)

However, if we change any of the factors to be incorrect, we should encounter an error and be unable to derive the key. You can use the key output to serve as a seed phrase.

Let me know if there’s anything else we can help with!

My apologies, I meant passkey but wrote passphrase.

No problem, here’s a full working example with passkey (using a client-side passkey with the Rust passkeys library):

use std::collections::HashMap;

use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use coset::iana;
use mfkdf2::definitions::{MFKDF2Options, factor::MFKDF2Factor};
use mfkdf2::setup::factors::{
    passkey::PasskeyOptions,
    totp::TOTPOptions,
    uuid::{UUIDOptions, Uuid},
};
use passkey::{
    authenticator::{Authenticator, UiHint, UserCheck, UserValidationMethod},
    client::Client,
    types::{
        Bytes, Passkey,
        ctap2::{Aaguid, Ctap2Error},
        rand::random_vec,
        webauthn::*,
    },
};
use passkey_authenticator::extensions::HmacSecretConfig;
use url::Url;

const RECOVERY_CODE: &str = "550e8400-e29b-41d4-a716-446655440000";

// Application-specific PRF salt: the authenticator computes HMAC-SHA256(CredRandom, SHA256("WebAuthn PRF\0" || salt))
const PRF_EVAL_SALT: &[u8] = b"mfkdf2-passkey-salt-v1";

// Stub user-validation that approves every request (simulates a platform authenticator unlock).
struct AlwaysApprove;

#[async_trait]
impl UserValidationMethod for AlwaysApprove {
    type PasskeyItem = Passkey;

    async fn check_user<'a>(
        &self,
        _hint: UiHint<'a, Self::PasskeyItem>,
        presence: bool,
        verification: bool,
    ) -> Result<UserCheck, Ctap2Error> {
        Ok(UserCheck { presence, verification })
    }

    fn is_verification_enabled(&self) -> Option<bool> { Some(true) }
    fn is_presence_enabled(&self) -> bool { true }
}

fn to_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

fn setup_factors(prf: [u8; 32]) -> Result<Vec<MFKDF2Factor>> {
    Ok(vec![
        mfkdf2::setup::factors::passkey::passkey(prf, PasskeyOptions::default())?,
        mfkdf2::setup::factors::totp(TOTPOptions {
            id:     Some("totp".to_string()),
            secret: Some(b"abcdefghijklmnopqrst".to_vec()),
            time:   Some(1),
            ..Default::default()
        })?,
        mfkdf2::setup::factors::uuid::uuid(UUIDOptions {
            id:   Some("recovery_code".to_string()),
            uuid: Some(Uuid::parse_str(RECOVERY_CODE)?),
        })?,
    ])
}

// Build a CredentialRequestOptions with PRF eval for the given credential.
fn prf_auth_request(credential_id: Bytes) -> CredentialRequestOptions {
    CredentialRequestOptions {
        public_key: PublicKeyCredentialRequestOptions {
            challenge:   random_vec(32).into(),
            timeout:     None,
            rp_id:       Some("example.com".to_string()),
            allow_credentials: Some(vec![PublicKeyCredentialDescriptor {
                ty:         PublicKeyCredentialType::PublicKey,
                id:         credential_id,
                transports: None,
            }]),
            user_verification: UserVerificationRequirement::Required,
            hints:              None,
            attestation:        AttestationConveyancePreference::None,
            attestation_formats: None,
            extensions: Some(AuthenticationExtensionsClientInputs {
                prf: Some(AuthenticationExtensionsPrfInputs {
                    eval: Some(AuthenticationExtensionsPrfValues {
                        first:  PRF_EVAL_SALT.to_vec().into(),
                        second: None,
                    }),
                    eval_by_credential: None,
                }),
                ..Default::default()
            }),
        },
    }
}

fn extract_prf(auth_result: AuthenticatedPublicKeyCredential) -> Result<[u8; 32]> {
    let prf_bytes: Vec<u8> = auth_result
        .client_extension_results
        .prf
        .context("PRF extension absent from auth result")?
        .results
        .context("PRF results absent")?
        .first
        .into();
    prf_bytes.try_into().map_err(|_| anyhow!("PRF output was not 32 bytes"))
}

#[tokio::main]
async fn main() -> Result<()> {
    let origin = Url::parse("https://example.com").unwrap();

    // Build a software authenticator with hmac-secret (PRF) support
    let authenticator = Authenticator::new(Aaguid::new_empty(), None::<Passkey>, AlwaysApprove)
        .hmac_secret(HmacSecretConfig::new_with_uv_only());
    let mut client = Client::new(authenticator);

    // ── Registration ──────────────────────────────────────────────────────────
    let reg = client
        .register(
            &origin,
            CredentialCreationOptions {
                public_key: PublicKeyCredentialCreationOptions {
                    rp: PublicKeyCredentialRpEntity { id: None, name: "example.com".into() },
                    user: PublicKeyCredentialUserEntity {
                        id:           random_vec(32).into(),
                        display_name: "Test User".into(),
                        name:         "test@example.com".into(),
                    },
                    challenge:           random_vec(32).into(),
                    pub_key_cred_params: vec![PublicKeyCredentialParameters {
                        ty:  PublicKeyCredentialType::PublicKey,
                        alg: iana::Algorithm::ES256,
                    }],
                    timeout:                 None,
                    exclude_credentials:     None,
                    authenticator_selection: None,
                    hints:                   None,
                    attestation:             AttestationConveyancePreference::None,
                    attestation_formats:     None,
                    // Enable PRF during registration (no eval output needed yet)
                    extensions: Some(AuthenticationExtensionsClientInputs {
                        prf: Some(AuthenticationExtensionsPrfInputs {
                            eval:               None,
                            eval_by_credential: None,
                        }),
                        ..Default::default()
                    }),
                },
            },
            None,
        )
        .await
        .map_err(|e| anyhow!("{e:?}"))?;

    let credential_id = reg.raw_id.clone();
    println!("Registered passkey (id: {}...)", &to_hex(&credential_id)[..16]);

    // ── Setup: authenticate → get PRF bytes → build MFKDF2 policy ─────────────
    let auth_setup = client
        .authenticate(&origin, prf_auth_request(credential_id.clone()), None)
        .await
        .map_err(|e| anyhow!("{e:?}"))?;
    let prf_setup = extract_prf(auth_setup)?;
    println!("PRF output:  {}", to_hex(&prf_setup));

    let setup = mfkdf2::setup::key(
        &setup_factors(prf_setup)?,
        MFKDF2Options { threshold: Some(1), ..MFKDF2Options::default() },
    )?;
    let setup_hex = to_hex(&setup.key);
    println!("Setup   key: {}", setup_hex);

    // ── Derive with passkey (re-authenticate → same PRF output) ───────────────
    let auth_derive = client
        .authenticate(&origin, prf_auth_request(credential_id.clone()), None)
        .await
        .map_err(|e| anyhow!("{e:?}"))?;
    let prf_derive = extract_prf(auth_derive)?;

    let from_passkey = mfkdf2::derive::key(
        &setup.policy,
        HashMap::from([("passkey".to_string(), mfkdf2::derive::factors::passkey(prf_derive)?)]),
        true,
        false,
    )?;
    let passkey_hex = to_hex(&from_passkey.key);
    println!("Passkey:     {} (match: {})", passkey_hex, passkey_hex == setup_hex);

    // ── Derive with TOTP only ─────────────────────────────────────────────────
    let from_totp = mfkdf2::derive::key(
        &setup.policy,
        HashMap::from([(
            "totp".to_string(),
            mfkdf2::derive::factors::totp(
                241063,
                Some(mfkdf2::derive::factors::totp::TOTPDeriveOptions {
                    time: Some(30001),
                    ..Default::default()
                }),
            )?,
        )]),
        true,
        false,
    )?;
    let totp_hex = to_hex(&from_totp.key);
    println!("TOTP:        {} (match: {})", totp_hex, totp_hex == setup_hex);

    // ── Derive with recovery code only ────────────────────────────────────────
    let from_recovery = mfkdf2::derive::key(
        &setup.policy,
        HashMap::from([(
            "recovery_code".to_string(),
            mfkdf2::derive::factors::uuid(Uuid::parse_str(RECOVERY_CODE)?)?,
        )]),
        true,
        false,
    )?;
    let recovery_hex = to_hex(&from_recovery.key);
    println!("Recovery:    {} (match: {})", recovery_hex, recovery_hex == setup_hex);

    Ok(())
}

Output:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
     Running `target\debug\zcash_example.exe`
Registered passkey (id: 847144ce499acb66...)
PRF output:  470b9a3e74a92b093f57b0b48cf95cea4704d7f639d4ce9a24fa1f2812356272
Setup   key: 1ff03acb598feed517c61582b5becd0c28379e4bf966d56be7d30843083d1c87
Passkey:     1ff03acb598feed517c61582b5becd0c28379e4bf966d56be7d30843083d1c87 (match: true)
TOTP:        1ff03acb598feed517c61582b5becd0c28379e4bf966d56be7d30843083d1c87 (match: true)
Recovery:    1ff03acb598feed517c61582b5becd0c28379e4bf966d56be7d30843083d1c87 (match: true)