use std::str::FromStr;
use bip39::{Language, Mnemonic};
use bitcoin::{NetworkKind, bip32::Xpriv, secp256k1::SecretKey};
use zeroize::Zeroizing;
use crate::{Error, Result};
use super::{PAYLOAD_CODE_STASH, XprvPayload};
const MAINNET_XPRV_VERSION: [u8; 4] = [0x04, 0x88, 0xad, 0xe4];
const STASH_LEN: usize = 72;
const STASH_MARKER_XPRV: u8 = 0x01;
const STASH_MARKER_MNEMONIC_FLAG: u8 = 0x80;
const STASH_MNEMONIC_ENTROPY_UNITS_MASK: u8 = 0x03;
const STASH_RAW_MASTER_SECRET_LEN: std::ops::RangeInclusive<u8> = 0x10..=0x40;
pub(super) enum DecodedStash {
Mnemonic(Mnemonic),
Xprv(XprvPayload),
}
pub(super) fn encode_mnemonic(mnemonic: &Mnemonic) -> Result<Zeroizing<Vec<u8>>> {
let entropy = Zeroizing::new(mnemonic_entropy(mnemonic)?);
if !matches!(entropy.len(), 16 | 24 | 32) {
return Err(Error::UnsupportedMnemonicWordCount(mnemonic.word_count()));
}
let marker = STASH_MARKER_MNEMONIC_FLAG | ((entropy.len() / 8) - 2) as u8;
let mut encoded = Zeroizing::new(Vec::with_capacity(1 + 1 + entropy.len()));
encoded.push(PAYLOAD_CODE_STASH);
encoded.push(marker);
encoded.extend_from_slice(&entropy);
trim_padding(&mut encoded);
Ok(encoded)
}
pub(super) fn encode_xprv(xprv: &XprvPayload) -> Result<Zeroizing<Vec<u8>>> {
let xprv = Xpriv::from_str(xprv.expose_string()).map_err(|_| Error::InvalidXprvPayload)?;
let private_key = Zeroizing::new(xprv.private_key.secret_bytes());
let mut encoded = Zeroizing::new(Vec::with_capacity(66));
encoded.push(PAYLOAD_CODE_STASH);
encoded.push(STASH_MARKER_XPRV);
encoded.extend_from_slice(xprv.chain_code.as_bytes());
encoded.extend_from_slice(private_key.as_ref());
trim_padding(&mut encoded);
Ok(encoded)
}
pub(super) fn decode(body: &[u8]) -> Result<DecodedStash> {
if body.is_empty() || body.len() > STASH_LEN {
return Err(Error::InvalidMnemonicPayload);
}
let mut stash = Zeroizing::new([0_u8; STASH_LEN]);
stash[..body.len()].copy_from_slice(body);
let marker = stash[0];
let rest = &stash[1..];
if marker == STASH_MARKER_XPRV {
return decode_xprv(rest).map(DecodedStash::Xprv);
}
if STASH_RAW_MASTER_SECRET_LEN.contains(&marker) {
let xprv = Xpriv::new_master(NetworkKind::Main, &rest[..usize::from(marker)])
.map_err(|_| Error::InvalidXprvPayload)?;
return Ok(DecodedStash::Xprv(XprvPayload::try_from_xpriv(xprv)?));
}
if marker & STASH_MARKER_MNEMONIC_FLAG == 0 {
return Err(Error::InvalidMnemonicPayload);
}
let entropy_len = usize::from((marker & STASH_MNEMONIC_ENTROPY_UNITS_MASK) + 2) * 8;
if !matches!(entropy_len, 16 | 24 | 32) || rest.len() < entropy_len {
return Err(Error::InvalidMnemonicPayload);
}
Ok(DecodedStash::Mnemonic(Mnemonic::from_entropy(&rest[..entropy_len])?))
}
fn mnemonic_entropy(mnemonic: &Mnemonic) -> Result<Vec<u8>> {
if mnemonic.language() != Language::English {
return Err(Error::UnsupportedMnemonicLanguage);
}
let entropy_len = mnemonic.word_count() / 3 * 4;
let mut entropy = vec![0_u8; entropy_len];
for (word_position, word_index) in mnemonic.word_indices().enumerate() {
for word_bit in 0..11 {
let entropy_bit = word_position * 11 + word_bit;
if entropy_bit >= entropy_len * 8 {
return Ok(entropy);
}
if word_index & (1 << (10 - word_bit)) != 0 {
entropy[entropy_bit / 8] |= 1 << (7 - entropy_bit % 8);
}
}
}
Ok(entropy)
}
fn decode_xprv(body: &[u8]) -> Result<XprvPayload> {
if body.len() != 71 {
return Err(Error::InvalidXprvPayload);
}
let chain_code = &body[..32];
let private_key = &body[32..64];
SecretKey::from_slice(private_key).map_err(|_| Error::InvalidXprvPayload)?;
let mut encoded = Zeroizing::new([0_u8; 78]);
encoded[0..4].copy_from_slice(&MAINNET_XPRV_VERSION);
encoded[13..45].copy_from_slice(chain_code);
encoded[45] = 0;
encoded[46..78].copy_from_slice(private_key);
let xprv = Xpriv::decode(&encoded[..])?;
XprvPayload::try_from_xpriv(xprv)
}
fn trim_padding(encoded: &mut Vec<u8>) {
while encoded.last() == Some(&0) {
encoded.pop();
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use bip39::Mnemonic;
use bitcoin::{NetworkKind, bip32::Xpriv, secp256k1::SecretKey};
use crate::payload::{DecodedPayload, Payload};
use super::{PAYLOAD_CODE_STASH, STASH_MARKER_MNEMONIC_FLAG, STASH_MARKER_XPRV, trim_padding};
const XPRV: &str = "xprv9s21ZrQH143K4BwRCYKSEPwcAMYweWkfKLURabnnv2GLNhJN1LSCgDQyGWyNcat72najQKwyshCBXWfHHVbcdxPAZPqByMyWDbWp5SjCfEa";
#[test]
fn mnemonic_stash_roundtrips_coldcard_trailing_zero_trimming() {
let mnemonic = Mnemonic::from_entropy(&[0_u8; 16]).unwrap();
let encoded = Payload::mnemonic(mnemonic.clone()).unwrap().encode().unwrap();
assert_eq!(encoded.as_slice(), &[PAYLOAD_CODE_STASH, STASH_MARKER_MNEMONIC_FLAG]);
assert_eq!(DecodedPayload::decode(&encoded).unwrap(), DecodedPayload::Mnemonic(mnemonic));
}
#[test]
fn xprv_encoder_uses_coldcard_stash_layout() {
let xprv = Xpriv::from_str(XPRV).unwrap();
let encoded = Payload::xprv(XPRV).unwrap().encode().unwrap();
assert_eq!(&encoded[..2], &[PAYLOAD_CODE_STASH, STASH_MARKER_XPRV]);
assert_eq!(&encoded[2..34], xprv.chain_code.as_bytes());
assert_eq!(&encoded[34..66], &xprv.private_key.secret_bytes());
}
#[test]
fn xprv_stash_decodes_after_coldcard_trims_private_key_zero() {
let chain_code = [2_u8; 32];
let mut private_key = [1_u8; 32];
private_key[31] = 0;
SecretKey::from_slice(&private_key).unwrap();
let mut encoded = vec![PAYLOAD_CODE_STASH, STASH_MARKER_XPRV];
encoded.extend_from_slice(&chain_code);
encoded.extend_from_slice(&private_key);
trim_padding(&mut encoded);
let DecodedPayload::Xprv(decoded) = DecodedPayload::decode(&encoded).unwrap() else {
panic!("expected xprv")
};
let decoded = Xpriv::from_str(decoded.expose_string()).unwrap();
assert_eq!(decoded.chain_code.as_bytes(), &chain_code);
assert_eq!(decoded.private_key.secret_bytes(), private_key);
}
#[test]
fn raw_master_secret_stash_decodes_as_bip32_master() {
for seed_len in [16_usize, 32, 64] {
let seed = vec![7_u8; seed_len];
let mut encoded = vec![PAYLOAD_CODE_STASH, seed_len as u8];
encoded.extend_from_slice(&seed);
let DecodedPayload::Xprv(decoded) = DecodedPayload::decode(&encoded).unwrap() else {
panic!("expected xprv for seed length {seed_len}")
};
let expected = Xpriv::new_master(NetworkKind::Main, &seed).unwrap();
assert_eq!(decoded.expose_string(), expected.to_string());
}
}
#[test]
fn raw_master_secret_stash_restores_trimmed_trailing_zeros() {
let mut seed = [9_u8; 24];
seed[20..].fill(0);
let mut encoded = vec![PAYLOAD_CODE_STASH, 24];
encoded.extend_from_slice(&seed);
trim_padding(&mut encoded);
assert!(encoded.len() < 26);
let DecodedPayload::Xprv(decoded) = DecodedPayload::decode(&encoded).unwrap() else {
panic!("expected xprv")
};
let expected = Xpriv::new_master(NetworkKind::Main, &seed).unwrap();
assert_eq!(decoded.expose_string(), expected.to_string());
}
#[test]
fn raw_master_secret_stash_rejects_out_of_range_lengths() {
for marker in [0x02_u8, 0x0f, 0x41, 0x7f] {
let mut encoded = vec![PAYLOAD_CODE_STASH, marker];
encoded.extend_from_slice(&[3_u8; 70]);
assert!(
matches!(
DecodedPayload::decode(&encoded),
Err(crate::Error::InvalidMnemonicPayload)
),
"marker 0x{marker:02x} should be rejected"
);
}
}
}