Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions zk-sdk-wasm-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ const ae = keys.ae(); // AeKey

`signerMessage` and `prfInput` both take a caller-chosen `public_seed` that scopes the derivation. It is granularity-agnostic: pass a token-account pubkey for per-account keys, or a wallet pubkey for one key across all of a wallet's accounts. The SDK does not enforce a convention, but two wallets must agree on it (and on which adapter they use) to derive the same keys for the same account.

For single-signer PDA wallets, use `pdaWalletPublicSeed` to bind the derived keys to the wallet program, wallet PDA, mint, and concrete token account:

```js
const publicSeed = ConfidentialKeys.pdaWalletPublicSeed(
programId.toBytes(),
walletPda.toBytes(),
mint.toBytes(),
tokenAccount.toBytes(),
);
```

### Passkeys (WebAuthn PRF)

Passkey ECDSA signing is randomized by spec, so signature-based derivation is impossible on passkey authenticators. The PRF (`hmac-secret`) extension is deterministic by construction and is the only path. PRF must be enabled when the credential is **registered**; legacy credentials that predate it cannot be used.
Expand Down
87 changes: 87 additions & 0 deletions zk-sdk-wasm-js/src/encryption/derivation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use {
solana_zk_sdk::encryption::derivation::{
confidential_derivation_message, derive_confidential_keys_from_ikm,
derive_confidential_keys_from_signature,
pda_wallet_public_seed as sdk_pda_wallet_public_seed, PDA_WALLET_PUBLIC_SEED_FIELD_LEN,
},
wasm_bindgen::prelude::{wasm_bindgen, JsValue},
};
Expand All @@ -16,6 +17,23 @@ const SIGNATURE_LEN: usize = 64;
/// `prf.results.first` evaluation, 64 bytes for `first || second` concatenated.
const PRF_OUTPUT_LENS: [usize; 2] = [32, 64];

fn copy_public_seed_field(
name: &str,
field: &Uint8Array,
) -> Result<[u8; PDA_WALLET_PUBLIC_SEED_FIELD_LEN], JsValue> {
if field.length() as usize != PDA_WALLET_PUBLIC_SEED_FIELD_LEN {
return Err(JsValue::from_str(&format!(
"Invalid {name} length: expected {}, got {}",
PDA_WALLET_PUBLIC_SEED_FIELD_LEN,
field.length()
)));
}

let mut bytes = [0u8; PDA_WALLET_PUBLIC_SEED_FIELD_LEN];
field.copy_to(&mut bytes);
Ok(bytes)
}

/// Container returned by the unified confidential-balances key derivation.
///
/// Both the ElGamal keypair and the AES (`decryptable_available_balance`
Expand Down Expand Up @@ -58,6 +76,26 @@ impl ConfidentialKeys {
confidential_derivation_message(&public_seed.to_vec())
}

/// Returns the canonical `public_seed` for single-signer PDA wallet accounts.
///
/// The output is `program_id || wallet_pda || mint || token_account`.
/// Pass it to `signerMessage` or `prfInput` so PDA/passkey wallets use a
/// consistent seed convention across implementations.
#[wasm_bindgen(js_name = "pdaWalletPublicSeed")]
pub fn pda_wallet_public_seed(
program_id: Uint8Array,
wallet_pda: Uint8Array,
mint: Uint8Array,
token_account: Uint8Array,
) -> Result<Vec<u8>, JsValue> {
let program_id = copy_public_seed_field("program_id", &program_id)?;
let wallet_pda = copy_public_seed_field("wallet_pda", &wallet_pda)?;
let mint = copy_public_seed_field("mint", &mint)?;
let token_account = copy_public_seed_field("token_account", &token_account)?;

Ok(sdk_pda_wallet_public_seed(&program_id, &wallet_pda, &mint, &token_account).to_vec())
}

/// Derives a `ConfidentialKeys` pair from a 64-byte ed25519 signature
/// over the message returned by `signerMessage`.
#[wasm_bindgen(js_name = "fromSignature")]
Expand Down Expand Up @@ -236,6 +274,55 @@ mod tests {
assert!(prf.starts_with(b"solana-conf-bal/v1"));
}

#[wasm_bindgen_test]
fn test_pda_wallet_public_seed_format() {
let seed = ConfidentialKeys::pda_wallet_public_seed(
Uint8Array::from([0x11u8; 32].as_ref()),
Uint8Array::from([0x22u8; 32].as_ref()),
Uint8Array::from([0x33u8; 32].as_ref()),
Uint8Array::from([0x44u8; 32].as_ref()),
)
.unwrap();

assert_eq!(seed.len(), 128);
assert_eq!(&seed[0..32], [0x11u8; 32].as_ref());
assert_eq!(&seed[32..64], [0x22u8; 32].as_ref());
assert_eq!(&seed[64..96], [0x33u8; 32].as_ref());
assert_eq!(&seed[96..128], [0x44u8; 32].as_ref());
}

#[wasm_bindgen_test]
fn test_pda_wallet_public_seed_rejects_wrong_length() {
let good = Uint8Array::from([0x11u8; 32].as_ref());
let bad = Uint8Array::from([0x22u8; 31].as_ref());

assert!(ConfidentialKeys::pda_wallet_public_seed(
bad.clone(),
good.clone(),
good.clone(),
good.clone(),
)
.is_err());
assert!(ConfidentialKeys::pda_wallet_public_seed(
good.clone(),
bad.clone(),
good.clone(),
good.clone(),
)
.is_err());
assert!(ConfidentialKeys::pda_wallet_public_seed(
good.clone(),
good.clone(),
bad.clone(),
good.clone(),
)
.is_err());
assert!(
ConfidentialKeys::pda_wallet_public_seed(good.clone(), good.clone(), good, bad,)
.is_err()
);
}

#[wasm_bindgen_test]
fn test_from_prf_determinism() {
let prf_output = [3u8; 32];
Expand Down
58 changes: 58 additions & 0 deletions zk-sdk/src/encryption/derivation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ pub const AE_HKDF_INFO: &[u8] = b"ae";
/// HKDF info string for the ElGamal secret scalar.
pub const ELGAMAL_HKDF_INFO: &[u8] = b"elgamal";

/// Byte length of a Solana address or pubkey field in a PDA-wallet public seed.
pub const PDA_WALLET_PUBLIC_SEED_FIELD_LEN: usize = 32;

/// Byte length of the canonical PDA-wallet public seed.
pub const PDA_WALLET_PUBLIC_SEED_LEN: usize = PDA_WALLET_PUBLIC_SEED_FIELD_LEN * 4;

/// Minimum acceptable IKM length when calling
/// [`derive_confidential_keys_from_ikm`] directly. Matches the
/// `ELGAMAL_SECRET_KEY_LEN` floor used elsewhere in the SDK.
Expand All @@ -89,6 +95,32 @@ pub fn confidential_derivation_message(public_seed: &[u8]) -> Vec<u8> {
[HKDF_SALT, public_seed].concat()
}

/// Returns the canonical `public_seed` for single-signer PDA wallet accounts.
///
/// The seed binds the confidential-balance keys to the wallet program, wallet
/// PDA, mint, and concrete token account:
///
/// ```text
/// program_id || wallet_pda || mint || token_account
/// ```
///
/// Use this output as the `public_seed` for [`confidential_derivation_message`],
/// [`derive_confidential_keys`], or the wasm `ConfidentialKeys.prfInput`
/// passkey path.
pub fn pda_wallet_public_seed(
program_id: &[u8; PDA_WALLET_PUBLIC_SEED_FIELD_LEN],
wallet_pda: &[u8; PDA_WALLET_PUBLIC_SEED_FIELD_LEN],
mint: &[u8; PDA_WALLET_PUBLIC_SEED_FIELD_LEN],
token_account: &[u8; PDA_WALLET_PUBLIC_SEED_FIELD_LEN],
) -> [u8; PDA_WALLET_PUBLIC_SEED_LEN] {
let mut seed = [0u8; PDA_WALLET_PUBLIC_SEED_LEN];
seed[0..32].copy_from_slice(program_id);
seed[32..64].copy_from_slice(wallet_pda);
seed[64..96].copy_from_slice(mint);
seed[96..128].copy_from_slice(token_account);
seed
}

/// Signs the canonical derivation message with `signer` and derives the
/// confidential-balances key pair.
///
Expand Down Expand Up @@ -216,6 +248,32 @@ mod tests {
assert!(message.ends_with(&public_seed));
}

#[test]
fn test_pda_wallet_public_seed_format() {
let program_id = [0x11u8; 32];
let wallet_pda = [0x22u8; 32];
let mint = [0x33u8; 32];
let token_account = [0x44u8; 32];

let seed = pda_wallet_public_seed(&program_id, &wallet_pda, &mint, &token_account);

assert_eq!(seed.len(), PDA_WALLET_PUBLIC_SEED_LEN);
assert_eq!(&seed[0..32], &program_id);
assert_eq!(&seed[32..64], &wallet_pda);
assert_eq!(&seed[64..96], &mint);
assert_eq!(&seed[96..128], &token_account);
}

#[test]
fn test_pda_wallet_public_seed_derivation_message_format() {
let seed = pda_wallet_public_seed(&[1u8; 32], &[2u8; 32], &[3u8; 32], &[4u8; 32]);
let message = confidential_derivation_message(&seed);

assert!(message.starts_with(HKDF_SALT));
assert_eq!(&message[HKDF_SALT.len()..], &seed);
assert_eq!(message.len(), HKDF_SALT.len() + PDA_WALLET_PUBLIC_SEED_LEN);
}

#[test]
fn test_derive_confidential_keys_from_ikm_known_vector() {
// Canonical test vector for the unified HKDF-SHA512 spine.
Expand Down