descriptor_encrypt/
lib.rs

1// Written in 2025 by Joshua Doman <joshsdoman@gmail.com>
2// SPDX-License-Identifier: CC0-1.0
3
4//! # Descriptor Encrypt
5//!
6//! A cryptographic system that encrypts Bitcoin wallet descriptors such that only those
7//! who can spend the funds can recover the descriptor.
8//!
9//! ## Overview
10//!
11//! Bitcoin wallet descriptors encode the spending conditions for Bitcoin outputs, including
12//! keys, scripts, and other requirements. While descriptors are powerful tools for representing
13//! wallet structures, securely backing them up presents a challenge - especially for
14//! multi-signature and complex script setups.
15//!
16//! This library implements a cryptographic system that allows any Bitcoin wallet descriptor to be
17//! encrypted with a security model that directly mirrors the descriptor's spending conditions:
18//!
19//! - If your wallet requires 2-of-3 keys to spend, it will require exactly 2-of-3 keys to decrypt
20//! - If your wallet uses a complex miniscript policy like "Either 2 keys OR (a timelock AND another key)",
21//!   the encryption follows this same logical structure
22//!
23//! ## How It Works
24//!
25//! The encryption mechanism works through several key innovations:
26//!
27//! 1. **Security Mirroring**: The descriptor's spending policy is analyzed and transformed into an
28//!    equivalent encryption policy
29//! 2. **Recursive Secret Sharing**: Shamir's Secret Sharing is applied recursively to split
30//!    encryption keys following the script's threshold requirements
31//! 3. **Per-Key Encryption**: Each share is encrypted with the corresponding public key from
32//!    the descriptor, ensuring only key holders can access them
33//! 4. **Compact Encoding**: Tag-based and LEB128 variable-length encoding is used to minimize the size
34//     of the encrypted data
35//! 5. **Payload Extraction**: Sensitive data, including the master fingerprints, public keys and xpubs,
36//     hashes, and timelocks, are extracted from the descriptor and encrypted
37//! 6. **Template Extraction**: The descriptor template and derivation paths remain visible in plaintext,
38//!    allowing key holders to derive the necessary public keys and recover the full descriptor
39//!
40//! ## Examples
41//!
42//! ### Encrypting a Descriptor
43//!
44//! ```rust
45//! use std::str::FromStr;
46//! use descriptor_encrypt::{encrypt, decrypt};
47//! use miniscript::descriptor::{Descriptor, DescriptorPublicKey};
48//!
49//! // Create a descriptor - a 2-of-3 multisig in this example
50//! let desc_str = "wsh(multi(2,\
51//!     03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7,\
52//!     036d2b085e9e382ed10b69fc311a03f8641ccfff21574de0927513a49d9a688a00,\
53//!     02e8445082a72f29b75ca48748a914df60622a609cacfce8ed0e35804560741d29\
54//! ))";
55//! let descriptor = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
56//!
57//! // Encrypt the descriptor
58//! let encrypted_data = encrypt(descriptor.clone()).unwrap();
59//!
60//! // Later, decrypt with the keys (in this example, only the first two keys are provided,
61//! // which is sufficient for a 2-of-3 multisig)
62//! let pk0 = DescriptorPublicKey::from_str("03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7").unwrap();
63//! let pk1 = DescriptorPublicKey::from_str("036d2b085e9e382ed10b69fc311a03f8641ccfff21574de0927513a49d9a688a00").unwrap();
64//! let first_two_keys = vec![pk0, pk1];
65//!
66//! // Recover the original descriptor
67//! let recovered_descriptor = decrypt(&encrypted_data, first_two_keys).unwrap();
68//! assert_eq!(descriptor.to_string(), recovered_descriptor.to_string());
69//! ```
70//!
71//! ### Getting Template and Derivation Paths
72//!
73//! ```rust,ignore
74//! use descriptor_encrypt::{encrypt, get_template, get_origin_derivation_paths};
75//! // (imports and setup as in previous example)
76//!
77//! let encrypted_data = encrypt(descriptor.clone()).unwrap();
78//!
79//! // Get a template descriptor with dummy keys (useful for analysis without revealing actual keys)
80//! let template = get_template(&encrypted_data).unwrap();
81//!
82//! // Extract only the derivation paths (useful for watch-only wallets)
83//! let paths = get_origin_derivation_paths(&encrypted_data).unwrap();
84//! ```
85//!
86//! ## Supported Descriptor Types
87//!
88//! The library supports all standard Bitcoin descriptor types:
89//!
90//! - Single-signature (`pkh`, `wpkh`, `tr` with internal key)
91//! - Multi-signature (`sh(multi)`, `wsh(multi)`, `sh(wsh(multi))`, etc.)
92//! - Taproot (`tr` with script trees)
93//! - Full Miniscript expressions (all logical operations, timelocks, hashlocks, etc.)
94//! - Nested combinations of the above
95//!
96//! ## Security Considerations
97//!
98//! This library ensures:
99//!
100//! - Only key holders can decrypt descriptors, following the descriptor's original threshold logic
101//! - Encrypted data reveals nothing about the keys or spending conditions without decryption
102//! - Structure template extraction is possible without exposing sensitive information
103//! - The encryption is deterministic, producing the same output given the same descriptor
104//!
105//! The security of the system relies on the security of ChaCha20-Poly1305 for encryption and
106//! Shamir's Secret Sharing for threshold access control.
107//!
108
109// Coding conventions
110#![deny(unsafe_code)]
111#![deny(non_upper_case_globals)]
112#![deny(non_camel_case_types)]
113#![deny(non_snake_case)]
114#![deny(unused_mut)]
115#![deny(dead_code)]
116#![deny(unused_imports)]
117#![deny(missing_docs)]
118
119#[cfg(not(any(feature = "std")))]
120compile_error!("`std` must be enabled");
121
122pub use bitcoin;
123pub use miniscript;
124
125mod payload;
126mod template;
127
128use anyhow::{Result, anyhow};
129use bitcoin::bip32::DerivationPath;
130use miniscript::{Descriptor, DescriptorPublicKey};
131use sha2::{Digest, Sha256};
132
133use crate::payload::ToDescriptorTree;
134
135const V0: u8 = 0;
136const V1: u8 = 1;
137
138/// Encrypts a descriptor such that it can only be recovered by a set of
139/// keys with access to the funds.
140pub fn encrypt(desc: Descriptor<DescriptorPublicKey>) -> Result<Vec<u8>> {
141    encrypt_with_version(V0, desc)
142}
143
144/// Identical to `encrypt` except it provides full secrecy during encryption. as no
145/// information is gained about key inclusion unless the descriptor can be decrypted.
146///
147/// Tradeoffs:
148/// - More private: no information is revealed from partial decryptions
149/// - Slower to decrypt: must try all possible combinations of keys. This is O((N+1)^K),
150///   where N is the number of keys and K is the number of shares.
151pub fn encrypt_with_full_secrecy(desc: Descriptor<DescriptorPublicKey>) -> Result<Vec<u8>> {
152    encrypt_with_version(V1, desc)
153}
154
155fn encrypt_with_version(version: u8, desc: Descriptor<DescriptorPublicKey>) -> Result<Vec<u8>> {
156    let (template, payload) = template::encode(desc.clone());
157
158    // Deterministically derive encryption key
159    let mut hasher = Sha256::new();
160    hasher.update(&template);
161    hasher.update(&payload);
162    let encryption_key = hasher.finalize();
163
164    // Encrypt payload and shard encryption key into encrypted shares (1 per key)
165    let nonce = [0u8; 12];
166    let (encrypted_shares, encrypted_payload) = match version {
167        V0 => {
168            payload::encrypt_with_authenticated_shards(desc, encryption_key.into(), nonce, payload)?
169        }
170        V1 => payload::encrypt_with_full_secrecy(desc, encryption_key.into(), nonce, payload)?,
171        _ => return Err(anyhow!("Unsupported version: {}", version)),
172    };
173
174    Ok([
175        vec![version],
176        template,
177        encrypted_shares.concat(),
178        encrypted_payload,
179    ]
180    .concat())
181}
182
183/// Decrypts an encrypted descriptor using a set of public keys with access to the funds
184pub fn decrypt(
185    data: &[u8],
186    pks: Vec<DescriptorPublicKey>,
187) -> Result<Descriptor<DescriptorPublicKey>> {
188    if data.is_empty() {
189        return Err(anyhow!("Empty data"));
190    }
191
192    let version = data[0];
193    let (data, share_size) = match version {
194        V0 => (&data[1..], 48_usize),
195        V1 => (&data[1..], 32_usize),
196        _ => return Err(anyhow!("Unsupported version: {}", version)),
197    };
198
199    let (template, size) = template::decode(data)?;
200
201    let num_keys = template.clone().to_tree().extract_keys().len();
202
203    if size + num_keys * 48 > data.len() {
204        return Err(anyhow!("Missing bytes"));
205    }
206
207    let encrypted_shares: Vec<Vec<u8>> = data[size..size + num_keys * share_size]
208        .chunks_exact(share_size)
209        .map(|chunk| chunk.to_vec())
210        .collect();
211
212    let encrypted_payload = &data[size + num_keys * share_size..];
213
214    let nonce = [0u8; 12];
215    let payload = match version {
216        V0 => payload::decrypt_with_authenticated_shards(
217            template.clone(),
218            encrypted_shares,
219            pks,
220            nonce,
221            encrypted_payload.to_vec(),
222        )?,
223        V1 => payload::decrypt_with_full_secrecy(
224            template.clone(),
225            encrypted_shares,
226            pks,
227            nonce,
228            encrypted_payload.to_vec(),
229        )?,
230        _ => unreachable!("unsupported version"),
231    };
232
233    let desc = template::decode_with_payload(data, &payload)?;
234
235    Ok(desc)
236}
237
238/// Returns a template with dummy keys, hashes, and timelocks
239pub fn get_template(data: &[u8]) -> Result<Descriptor<DescriptorPublicKey>> {
240    if data.is_empty() {
241        return Err(anyhow!("Empty data"));
242    }
243
244    let data = match data[0] {
245        V0 => &data[1..],
246        _ => return Err(anyhow!("Unsupported version: {}", data[0])),
247    };
248
249    let (template, _) = template::decode(data)?;
250
251    Ok(template)
252}
253
254/// Returns the origin derivation paths in the descriptor
255pub fn get_origin_derivation_paths(data: &[u8]) -> Result<Vec<DerivationPath>> {
256    if data.is_empty() {
257        return Err(anyhow!("Empty data"));
258    }
259
260    let data = match data[0] {
261        V0 => &data[1..],
262        _ => return Err(anyhow!("Unsupported version: {}", data[0])),
263    };
264
265    let (template, _) = template::decode(data)?;
266
267    let mut paths = Vec::new();
268    for key in template.clone().to_tree().extract_keys() {
269        let origin = match key {
270            DescriptorPublicKey::XPub(xpub) => xpub.origin,
271            DescriptorPublicKey::MultiXPub(xpub) => xpub.origin,
272            DescriptorPublicKey::Single(single) => single.origin,
273        };
274
275        if let Some((_, path)) = origin {
276            paths.push(path);
277        }
278    }
279
280    Ok(paths)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::payload::ToDescriptorTree;
287    use miniscript::{Descriptor, DescriptorPublicKey};
288    use std::str::FromStr;
289
290    #[test]
291    fn test_integration() {
292        let descriptors = vec![
293            "sh(sortedmulti(2,[2c49202a/45h/0h/0h/0]xpub6EigxozzGaNVWUwEFnbyX6oHPdpWTKgJgbfpRbAcdiGpGMrdpPinCoHBXehu35sqJHpgLDTxigAnFQG3opKjXQoSmGMrMNHz81ALZSBRCWw/0/*,[55b43a50/45h/0h/0h/0]xpub6EAtA5XJ6pwFQ7L32iAJMgiWQEcrwU75NNWQ6H6eavwznDFeGFzTbSFdDKNdbG2HQdZvzrXuCyEYSSJ4cGsmfoPkKUKQ6haNKMRqG4pD4xi/0/*,[35931b5e/0/0/0/0]xpub6EDykLBC5EfaDNC7Mpg2H8veCaJHDgxH2JQvRtxJrbyeAhXWV2jJzB9XL4jMiFN5TzQefYi4V4nDiH4bxhkrweQ3Smxc8uP4ux9HrMGV81P/0/*))#eqwew7sv",
294            "wsh(sortedmulti(2,[3abf21c8/48h/0h/0h/2h]xpub6DYotmPf2kXFYhJMFDpfydjiXG1RzmH1V7Fnn2Z38DgN2oSYruczMyTFZZPz6yXq47Re8anhXWGj4yMzPTA3bjPDdpA96TLUbMehrH3sBna/<0;1>/*,[a1a4bd46/48h/0h/0h/2h]xpub6DvXYo8BwnRACos42ME7tNL48JQhLMQ33ENfniLM9KZmeZGbBhyh1Jkfo3hUKmmjW92o3r7BprTPPdrTr4QLQR7aRnSBfz1UFMceW5ibhTc/<0;1>/*,[ed91913d/48h/0h/0h/2h]xpub6EQUho4Z4pwh2UQGdPjoPrbtjd6qqseKZCEBLcZbJ7y6c9XBWHRkhERiADJfwRcUs14nQsxF3hvx7aFkbk3tfp4dnKfkcns217kBTVVN5gY/<0;1>/*))#hpcyqx44",
295            "sh(wsh(sortedmulti(2,[2c49202a/45h/0h/0h/0]xpub6EigxozzGaNVWUwEFnbyX6oHPdpWTKgJgbfpRbAcdiGpGMrdpPinCoHBXehu35sqJHpgLDTxigAnFQG3opKjXQoSmGMrMNHz81ALZSBRCWw/0/*,[55b43a50/45h/0h/0h/0]xpub6EAtA5XJ6pwFQ7L32iAJMgiWQEcrwU75NNWQ6H6eavwznDFeGFzTbSFdDKNdbG2HQdZvzrXuCyEYSSJ4cGsmfoPkKUKQ6haNKMRqG4pD4xi/0/*,[35931b5e/0/0/0/0]xpub6EDykLBC5EfaDNC7Mpg2H8veCaJHDgxH2JQvRtxJrbyeAhXWV2jJzB9XL4jMiFN5TzQefYi4V4nDiH4bxhkrweQ3Smxc8uP4ux9HrMGV81P/0/*)))#xsfvldas",
296            "wsh(multi(2,02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e,03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556,023e9be8b82c7469c88b1912a61611dffb9f65bbf5a176952727e0046513eca0de))",
297            "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)",
298            "sh(wsh(or_d(pk(03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556),and_v(v:pk(02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e),older(1000)))))",
299            "wsh(thresh(4,pk([7258e4f9/44h/1h/0h]tpubDCZrkQoEU3845aFKUu9VQBYWZtrTwxMzcxnBwKFCYXHD6gEXvtFcxddCCLFsEwmxQaG15izcHxj48SXg1QS5FQGMBx5Ak6deXKPAL7wauBU/0/*),s:pk([c80b1469/44h/1h/0h]tpubDD3UwwHoNUF4F3Vi5PiUVTc3ji1uThuRfFyBexTSHoAcHuWW2z8qEE2YujegcLtgthr3wMp3ZauvNG9eT9xfJyxXCfNty8h6rDBYU8UU1qq/0/*),s:pk([4e5024fe/44h/1h/0h]tpubDDLrpPymPLSCJyCMLQdmcWxrAWwsqqssm5NdxT2WSdEBPSXNXxwbeKtsHAyXPpLkhUyKovtZgCi47QxVpw9iVkg95UUgeevyAqtJ9dqBqa1/0/*),s:pk([3b1d1ee9/44h/1h/0h]tpubDCmDTANBWPzf6d8Ap1J5Ku7J1Ay92MpHMrEV7M5muWxCrTBN1g5f1NPcjMEL6dJHxbvEKNZtYCdowaSTN81DAyLsmv6w6xjJHCQNkxrsrfu/0/*),sln:after(840000),sln:after(1050000),sln:after(1260000)))#k28080kv",
300            "tr(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,{pk(fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556),pk(e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)})",
301        ];
302
303        for desc_str in descriptors {
304            let desc = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
305
306            let keys = desc.clone().to_tree().extract_keys();
307            let ciphertext = encrypt(desc.clone()).unwrap();
308            assert_eq!(desc, decrypt(&ciphertext, keys.clone()).unwrap());
309
310            let ciphertext = encrypt_with_full_secrecy(desc.clone()).unwrap();
311            assert_eq!(desc, decrypt(&ciphertext, keys).unwrap());
312        }
313    }
314
315    #[test]
316    fn test_unsupported_version() {
317        let desc_str = "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)";
318        let desc = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
319
320        // Modify the version byte to an invalid version
321        let mut encrypted_data = encrypt(desc.clone()).unwrap();
322        encrypted_data[0] = 0xFF;
323
324        let template_result = get_template(&encrypted_data);
325        assert!(
326            template_result
327                .unwrap_err()
328                .to_string()
329                .contains("Unsupported version: 255")
330        );
331
332        let paths_result = get_origin_derivation_paths(&encrypted_data);
333        assert!(
334            paths_result
335                .unwrap_err()
336                .to_string()
337                .contains("Unsupported version: 255")
338        );
339
340        let key = DescriptorPublicKey::from_str(
341            "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
342        )
343        .unwrap();
344
345        let decrypt_result = decrypt(&encrypted_data, vec![key]);
346        assert!(
347            decrypt_result
348                .unwrap_err()
349                .to_string()
350                .contains("Unsupported version: 255")
351        );
352    }
353
354    #[test]
355    fn test_empty() {
356        let empty_data: Vec<u8> = vec![];
357
358        let template_result = get_template(&empty_data);
359        assert!(
360            template_result
361                .unwrap_err()
362                .to_string()
363                .contains("Empty data")
364        );
365
366        let paths_result = get_origin_derivation_paths(&empty_data);
367        assert!(paths_result.unwrap_err().to_string().contains("Empty data"));
368
369        let decrypt_result = decrypt(
370            &empty_data,
371            vec![
372                DescriptorPublicKey::from_str(
373                    "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
374                )
375                .unwrap(),
376            ],
377        );
378        assert!(
379            decrypt_result
380                .unwrap_err()
381                .to_string()
382                .contains("Empty data")
383        );
384    }
385}