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, as if all timelocks and hashlocks are satisfied
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 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 to recover the full descriptor
39//!
40//! ## Usage
41//!
42//! ```rust
43//! use std::str::FromStr;
44//! use descriptor_encrypt::{encrypt, encrypt_with_full_secrecy, decrypt, get_template, get_origin_derivation_paths};
45//! use miniscript::descriptor::{Descriptor, DescriptorPublicKey};
46//!
47//! // Create a descriptor - a 2-of-3 multisig in this example
48//! let desc_str = "wsh(multi(2,\
49//!     03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7,\
50//!     036d2b085e9e382ed10b69fc311a03f8641ccfff21574de0927513a49d9a688a00,\
51//!     02e8445082a72f29b75ca48748a914df60622a609cacfce8ed0e35804560741d29\
52//! ))";
53//! let descriptor = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
54//!
55//! // Encrypt the descriptor
56//! let encrypted_data = encrypt(descriptor.clone()).unwrap();
57//!
58//! // Encrypt the descriptor with full secrecy (best for privacy but slower when decrypting large descriptors)
59//! let encrypted_data_with_full_secrecy = encrypt(descriptor.clone()).unwrap();
60//!
61//! // Get a template descriptor with dummy keys, hashes, and timelocks
62//! let template = get_template(&encrypted_data).unwrap();
63//!
64//! // Extract only the derivation paths (useful for deriving xpubs)
65//! let paths = get_origin_derivation_paths(&encrypted_data).unwrap();
66//!
67//! // Later, decrypt with the keys (in this example, only the first two keys are provided,
68//! // which is sufficient for a 2-of-3 multisig)
69//! let pk0 = DescriptorPublicKey::from_str("03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7").unwrap();
70//! let pk1 = DescriptorPublicKey::from_str("036d2b085e9e382ed10b69fc311a03f8641ccfff21574de0927513a49d9a688a00").unwrap();
71//! let first_two_keys = vec![pk0, pk1];
72//!
73//! // Recover the original descriptor
74//! let recovered_descriptor = decrypt(&encrypted_data, first_two_keys).unwrap();
75//! assert_eq!(descriptor.to_string(), recovered_descriptor.to_string());
76//! ```
77//!
78//! ## Supported Descriptor Types
79//!
80//! The library supports all standard Bitcoin descriptor types:
81//!
82//! - Single-signature (`pkh`, `wpkh`, `tr` with internal key)
83//! - Multi-signature (`sh(multi)`, `wsh(multi)`, `sh(wsh(multi))`, etc.)
84//! - Taproot (`tr` with script trees)
85//! - Full Miniscript expressions (all logical operations, timelocks, hashlocks, etc.)
86//! - Nested combinations of the above
87//!
88//! ## Security Considerations
89//!
90//! This library ensures:
91//!
92//! - Only key holders can decrypt descriptors, following the descriptor's original threshold logic
93//! - Encrypted data reveals nothing about the keys or spending conditions without decryption
94//! - Template extraction is possible without exposing sensitive information
95//! - The encryption is deterministic, producing the same output given the same descriptor
96//!
97//! The security of the system relies on the security of ChaCha20(Poly1305) for encryption and
98//! Shamir Secret Sharing for threshold access control.
99//!
100
101// Coding conventions
102#![deny(unsafe_code)]
103#![deny(non_upper_case_globals)]
104#![deny(non_camel_case_types)]
105#![deny(non_snake_case)]
106#![deny(unused_mut)]
107#![deny(dead_code)]
108#![deny(unused_imports)]
109#![deny(missing_docs)]
110
111#[cfg(not(any(feature = "std")))]
112compile_error!("`std` must be enabled");
113
114pub use bitcoin;
115pub use miniscript;
116
117mod payload;
118mod template;
119
120use anyhow::{Result, anyhow};
121use bitcoin::bip32::DerivationPath;
122use descriptor_tree::ToDescriptorTree;
123use miniscript::{Descriptor, DescriptorPublicKey};
124use sha2::{Digest, Sha256};
125
126const V0: u8 = 0;
127const V1: u8 = 1;
128
129/// Encrypts a descriptor such that it can only be recovered by a set of
130/// keys with access to the funds.
131pub fn encrypt(desc: Descriptor<DescriptorPublicKey>) -> Result<Vec<u8>> {
132    encrypt_with_version(V0, desc)
133}
134
135/// Identical to `encrypt` except it provides full secrecy during encryption. as no
136/// information is gained about key inclusion unless the descriptor can be decrypted.
137///
138/// Tradeoffs:
139/// - More private: no information is revealed from partial decryptions
140/// - Slower to decrypt: must try all possible combinations of keys. This is O((N+1)^K),
141///   where N is the number of keys and K is the number of shares.
142pub fn encrypt_with_full_secrecy(desc: Descriptor<DescriptorPublicKey>) -> Result<Vec<u8>> {
143    encrypt_with_version(V1, desc)
144}
145
146fn encrypt_with_version(version: u8, desc: Descriptor<DescriptorPublicKey>) -> Result<Vec<u8>> {
147    let (template, payload) = template::encode(desc.clone());
148
149    // Deterministically derive encryption key
150    let mut hasher = Sha256::new();
151    hasher.update(&template);
152    hasher.update(&payload);
153    let encryption_key = hasher.finalize();
154
155    // Encrypt payload and shard encryption key into encrypted shares (1 per key)
156    let nonce = [0u8; 12];
157    let (encrypted_shares, encrypted_payload) = match version {
158        V0 => {
159            payload::encrypt_with_authenticated_shards(desc, encryption_key.into(), nonce, payload)?
160        }
161        V1 => payload::encrypt_with_full_secrecy(desc, encryption_key.into(), nonce, payload)?,
162        _ => return Err(anyhow!("Unsupported version: {}", version)),
163    };
164
165    Ok([
166        vec![version],
167        template,
168        encrypted_shares.concat(),
169        encrypted_payload,
170    ]
171    .concat())
172}
173
174/// Decrypts an encrypted descriptor using a set of public keys with access to the funds
175pub fn decrypt(
176    data: &[u8],
177    pks: Vec<DescriptorPublicKey>,
178) -> Result<Descriptor<DescriptorPublicKey>> {
179    if data.is_empty() {
180        return Err(anyhow!("Empty data"));
181    }
182
183    let version = data[0];
184    let (data, share_size) = match version {
185        V0 => (&data[1..], 48_usize),
186        V1 => (&data[1..], 32_usize),
187        _ => return Err(anyhow!("Unsupported version: {}", version)),
188    };
189
190    let (template, size) = template::decode(data)?;
191
192    let num_keys = if let Some(pruned_tree) = template.clone().to_tree().prune_keyless() {
193        pruned_tree.extract_keys().len()
194    } else {
195        0
196    };
197
198    if size + num_keys * 48 > data.len() {
199        return Err(anyhow!("Missing bytes"));
200    }
201
202    let encrypted_shares: Vec<Vec<u8>> = data[size..size + num_keys * share_size]
203        .chunks_exact(share_size)
204        .map(|chunk| chunk.to_vec())
205        .collect();
206
207    let encrypted_payload = &data[size + num_keys * share_size..];
208
209    let nonce = [0u8; 12];
210    let payload = match version {
211        V0 => payload::decrypt_with_authenticated_shards(
212            template.clone(),
213            encrypted_shares,
214            pks,
215            nonce,
216            encrypted_payload.to_vec(),
217        )?,
218        V1 => payload::decrypt_with_full_secrecy(
219            template.clone(),
220            encrypted_shares,
221            pks,
222            nonce,
223            encrypted_payload.to_vec(),
224        )?,
225        _ => unreachable!("unsupported version"),
226    };
227
228    let desc = template::decode_with_payload(data, &payload)?;
229
230    Ok(desc)
231}
232
233/// Returns a template with dummy keys, hashes, and timelocks
234pub fn get_template(data: &[u8]) -> Result<Descriptor<DescriptorPublicKey>> {
235    if data.is_empty() {
236        return Err(anyhow!("Empty data"));
237    }
238
239    let data = match data[0] {
240        V0 | V1 => &data[1..],
241        _ => return Err(anyhow!("Unsupported version: {}", data[0])),
242    };
243
244    let (template, _) = template::decode(data)?;
245
246    Ok(template)
247}
248
249/// Returns the origin derivation paths in the descriptor
250pub fn get_origin_derivation_paths(data: &[u8]) -> Result<Vec<DerivationPath>> {
251    if data.is_empty() {
252        return Err(anyhow!("Empty data"));
253    }
254
255    let data = match data[0] {
256        V0 | V1 => &data[1..],
257        _ => return Err(anyhow!("Unsupported version: {}", data[0])),
258    };
259
260    let (template, _) = template::decode(data)?;
261
262    let mut paths = Vec::new();
263    for key in template.clone().to_tree().extract_keys() {
264        let origin = match key {
265            DescriptorPublicKey::XPub(xpub) => xpub.origin,
266            DescriptorPublicKey::MultiXPub(xpub) => xpub.origin,
267            DescriptorPublicKey::Single(single) => single.origin,
268        };
269
270        if let Some((_, path)) = origin {
271            paths.push(path);
272        }
273    }
274
275    Ok(paths)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use descriptor_tree::ToDescriptorTree;
282    use miniscript::{Descriptor, DescriptorPublicKey};
283    use std::str::FromStr;
284
285    #[test]
286    fn test_integration() {
287        let descriptors = vec![
288            "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",
289            "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",
290            "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",
291            "wsh(multi(2,02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e,03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556,023e9be8b82c7469c88b1912a61611dffb9f65bbf5a176952727e0046513eca0de))",
292            "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)",
293            "sh(wsh(or_d(pk(03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556),and_v(v:pk(02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e),older(1000)))))",
294            "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",
295            "tr(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,{pk(fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556),pk(e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)})",
296        ];
297
298        for desc_str in descriptors {
299            let desc = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
300
301            let keys = desc.clone().to_tree().extract_keys();
302            let ciphertext = encrypt(desc.clone()).unwrap();
303            assert_eq!(desc, decrypt(&ciphertext, keys.clone()).unwrap());
304            assert!(get_template(&ciphertext).is_ok());
305            assert!(get_origin_derivation_paths(&ciphertext).is_ok());
306
307            let ciphertext = encrypt_with_full_secrecy(desc.clone()).unwrap();
308            assert_eq!(desc, decrypt(&ciphertext, keys).unwrap());
309            assert!(get_template(&ciphertext).is_ok());
310            assert!(get_origin_derivation_paths(&ciphertext).is_ok());
311        }
312    }
313
314    #[test]
315    fn test_unsupported_version() {
316        let desc_str = "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)";
317        let desc = Descriptor::<DescriptorPublicKey>::from_str(desc_str).unwrap();
318
319        // Modify the version byte to an invalid version
320        let mut encrypted_data = encrypt(desc.clone()).unwrap();
321
322        for i in 2..0xFF {
323            encrypted_data[0] = i;
324
325            let template_result = get_template(&encrypted_data);
326            assert!(
327                template_result
328                    .unwrap_err()
329                    .to_string()
330                    .contains(&format!("Unsupported version: {}", i))
331            );
332
333            let paths_result = get_origin_derivation_paths(&encrypted_data);
334            assert!(
335                paths_result
336                    .unwrap_err()
337                    .to_string()
338                    .contains(&format!("Unsupported version: {}", i))
339            );
340
341            let key = DescriptorPublicKey::from_str(
342                "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
343            )
344            .unwrap();
345
346            let decrypt_result = decrypt(&encrypted_data, vec![key]);
347            assert!(
348                decrypt_result
349                    .unwrap_err()
350                    .to_string()
351                    .contains(&format!("Unsupported version: {}", i))
352            );
353        }
354    }
355
356    #[test]
357    fn test_empty() {
358        let empty_data: Vec<u8> = vec![];
359
360        let template_result = get_template(&empty_data);
361        assert!(
362            template_result
363                .unwrap_err()
364                .to_string()
365                .contains("Empty data")
366        );
367
368        let paths_result = get_origin_derivation_paths(&empty_data);
369        assert!(paths_result.unwrap_err().to_string().contains("Empty data"));
370
371        let decrypt_result = decrypt(
372            &empty_data,
373            vec![
374                DescriptorPublicKey::from_str(
375                    "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
376                )
377                .unwrap(),
378            ],
379        );
380        assert!(
381            decrypt_result
382                .unwrap_err()
383                .to_string()
384                .contains("Empty data")
385        );
386    }
387}