Skip to content

WIP: implemented encrypted backup BIP138#109

Draft
Sjors wants to merge 22 commits into
masterfrom
wip-encrypted-backup
Draft

WIP: implemented encrypted backup BIP138#109
Sjors wants to merge 22 commits into
masterfrom
wip-encrypted-backup

Conversation

@Sjors

@Sjors Sjors commented Jan 14, 2026

Copy link
Copy Markdown
Owner

Implements bitcoin/bips#1951

DO NOT USE YET

Backups generated with this code may become inaccessible if the BIP changes!

There's no guarantee this will ever make it into Bitcoin Core, and I might not maintain this branch.

It's also entirely vibe coded. Despite much back and forth with Claude and its buddy Codex, I have not yet thoroughly reviewed every commit it wrote.

Relevant upstream PRs:

@pythcoiner

Copy link
Copy Markdown

in 7552892, H marker should also be allowed I think:

diff --git a/src/util/bip32.cpp b/src/util/bip32.cpp
index 94b1fcc027..14775dc79a 100644
--- a/src/util/bip32.cpp
+++ b/src/util/bip32.cpp
@@ -29,6 +29,9 @@ bool ParseHDKeypath(const std::string& keypath_str, std::vector<uint32_t>& keypa
         if (pos == std::string::npos) {
             pos = item.find('h');
         }
+        if (pos == std::string::npos) {
+            pos = item.find('H');
+        }
         if (pos != std::string::npos) {
             // The hardened tick can only be in the last index of the string
             if (pos != item.size() - 1) {
diff --git a/src/wallet/test/psbt_wallet_tests.cpp b/src/wallet/test/psbt_wallet_tests.cpp
index 5d8955e8c5..13c0f03753 100644
--- a/src/wallet/test/psbt_wallet_tests.cpp
+++ b/src/wallet/test/psbt_wallet_tests.cpp
@@ -135,6 +135,9 @@ BOOST_AUTO_TEST_CASE(parse_hd_keypath)
     BOOST_CHECK(ParseHDKeypath("m/0h/0h", keypath));
     BOOST_CHECK(!ParseHDKeypath("m/h0/0h", keypath));
 
+    BOOST_CHECK(ParseHDKeypath("m/0H/0H", keypath));
+    BOOST_CHECK(!ParseHDKeypath("m/H0/0H", keypath));
+
     BOOST_CHECK(ParseHDKeypath("m/0/0", keypath));
     BOOST_CHECK(!ParseHDKeypath("n/0/0", keypath));

Comment thread src/wallet/encryptedbackup.cpp Outdated
// CPubKey is either compressed (33 bytes) or uncompressed (65 bytes)
// In both cases, bytes 1-32 are the x-coordinate
uint256 result;
if (pubkey.size() >= 33) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not knowledgeable enough about the codebase, but is there some case where a CPubKey.size() could be 0 or 32?

it looks to me this if is confusing, because if it can resolve to false, we return a wrong (zeroized) key.

Comment thread src/wallet/encryptedbackup.h Outdated
* Handles:
* - Extended public keys (xpubs): extracts the pubkey and returns x-coordinate
* - Compressed public keys (33 bytes): strips the prefix byte
* - X-only public keys (32 bytes): returns as-is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not what the function return, in that case it seems returning a zeroized key

Comment thread src/wallet/encryptedbackup.cpp Outdated
Comment on lines +123 to +126
// Require "m" prefix for BIP-xxxx paths
if (path_str.empty() || path_str[0] != 'm') {
return util::Error{Untranslated("Derivation path must start with 'm'")};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m is optional in bip32

return util::Error{Untranslated("Truncated derivation path data")};
}

uint8_t child_count = data[pos++];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

        uint8_t child_count = data[pos++];
        if (child_count == 0) {
            return util::Error{Untranslated("Child count must be > 0")};
        }

@Sjors

Sjors commented Apr 15, 2026

Copy link
Copy Markdown
Owner Author

@pythcoiner thanks for the feedback, I plan to update this soon(tm).

@pythcoiner

Copy link
Copy Markdown

i'm working on a branch that is based on this, I'd like to to have it's test passing on the test vectors generated from the rust implem: pythcoiner#2

@pythcoiner

Copy link
Copy Markdown

@Sjors is there a reason to implement encryptbackup/decryptbackup/inspectbackup commands in wallet-tool rather than bitcoind?

@Sjors Sjors changed the title WIP: implemented encrypted backup BIP-xxxx WIP: implemented encrypted backup BIP138 May 22, 2026
@Sjors Sjors force-pushed the wip-encrypted-backup branch from 90fe3c7 to e69813c Compare May 22, 2026 10:02
@Sjors

Sjors commented May 22, 2026

Copy link
Copy Markdown
Owner Author

in wallet-tool rather than bitcoind?

To keep the latter from becoming even more complex. Both binaries are shipped in the Bitcoin Core distribution, so it shouldn't be a problem. The core functionality lives should live in CWallet, so it can be exposed via the GUI later, if needed.

Rebased and updated test vectors, doing some additional refactoring now...

@Sjors Sjors force-pushed the wip-encrypted-backup branch from e69813c to d837001 Compare May 22, 2026 11:36
@Sjors

Sjors commented May 22, 2026

Copy link
Copy Markdown
Owner Author

I moved more functionality to CWallet so it can be used outside wallet-tool. Additionally, similar to bitcoin#34861, all calls go through the Wallet interface.

I added more test vectors from the BIP. Some vectors still to use BIPXXX, which I changed to BIP388 pending bitcoin/bips#1951 (comment).

While comparing this implementation with the BIP, my agent found a mismatch between prose and test vectors. Commit 9fa12f7 changes the test vectors, but we could also change the prose - I haven't thought about it deeply.

@Sjors Sjors force-pushed the wip-encrypted-backup branch 2 times, most recently from 8dc383f to d1016ab Compare May 22, 2026 14:41
@Sjors

Sjors commented May 22, 2026

Copy link
Copy Markdown
Owner Author

Got rid of 65c4a4e in favor of some trivial serialization code in encryptedbackup.cpp.

We're now actually using struct WalletDescriptorInfo introduced in 13096ee.

I also cleaned up 17a25e6 to make better use of existing crypto code (8330771 + e57aae0).

Misc. other cleanups in the first couple of commits.

I plan to refine the commits in this PR further another time, as that should give me a better sense which parts of the BIP are potentially problematic (i.e. actually tedious to implement, not just bad slop on my end).

@Sjors Sjors force-pushed the wip-encrypted-backup branch from d1016ab to 1e89b29 Compare May 22, 2026 15:34
@Sjors

Sjors commented May 22, 2026

Copy link
Copy Markdown
Owner Author

I changed 8330771 -> f3c5471 to instead have GetPubKeys filter by exclude_observable. That makes it more clear what the intention is, and why the extra complexity is probably justified. That said, I find the implementation confusing.

I added a test vector (see commit message) to clarify musig2(xpub1,xpub2) is allowed. It's impossible to derive the original xpub participants from an aggregated key on chain.

@Sjors Sjors force-pushed the wip-encrypted-backup branch from 1e89b29 to 80f46c9 Compare May 22, 2026 16:02
@Sjors

Sjors commented May 22, 2026

Copy link
Copy Markdown
Owner Author

f3c5471 -> f2482dc now it makes more sense

@Sjors Sjors force-pushed the wip-encrypted-backup branch from 80f46c9 to ad79520 Compare May 22, 2026 16:56
@Sjors

Sjors commented May 22, 2026

Copy link
Copy Markdown
Owner Author

Bunch more cleanup, e.g.:

  • no more re-implementing EncryptChaCha20Poly1305
  • shrank wallet: BIP138 derivation path encoding a bit.
  • split the ChaCha20-Poly1305 stuff out of wallet: BIP138 full encrypted backup

@Sjors Sjors force-pushed the wip-encrypted-backup branch from ad79520 to b354c23 Compare May 26, 2026 16:04
@Sjors

Sjors commented May 26, 2026

Copy link
Copy Markdown
Owner Author

I implemented the descriptor format that I suggested in bitcoin/bips#1951 (comment). Also added a -compact argument to the export command to support the .txt format.

Also implemented the TLV change suggested here: https://github.com/bitcoin/bips/pull/1951/changes#r3304541662

decryptbackup can now import directly into a wallet. It can still optionally print an importdescriptors incantation. This involved pulling in bitcoin#34861 (first three commits).

Also pushed more cleanup:

  • use std::transform for the xor operation

@Sjors Sjors force-pushed the wip-encrypted-backup branch from b354c23 to 915eaed Compare May 27, 2026 07:57
@Sjors

Sjors commented May 27, 2026

Copy link
Copy Markdown
Owner Author

Repaired one of the intermediate commits that didn't pass CI.

@Sjors Sjors force-pushed the wip-encrypted-backup branch from 915eaed to ed40eea Compare June 19, 2026 17:58
@Sjors

Sjors commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Updated to track the latest draft BIP a thttps://github.com/bitcoin/bips/pull/1951/changes/99217c680f31792e576838d6879365303e7f7cbf. I was able to drop the customization commits d5200e2, e4c7077 and 1153d52.

Still missing support for multiple records (inside the plain-text is good enough). So I updated 3567cd0 -> 359b956 with a proposed change.

Other than that, this branch should match the BIP (minus BIP388 support).

@Sjors Sjors force-pushed the wip-encrypted-backup branch from ed40eea to d866388 Compare June 22, 2026 09:27
polespinasa and others added 22 commits June 30, 2026 19:36
Add two new structs used for creating requests and getting response when importing descriptors.

These structs replace the UniValue-based interface of ProcessDescriptorImport()
in a subsequent commit.
Rename ProcessDescriptorImport() to ImportDescriptor() and replace its
UniValue-based arguments and return value with the ImportDescriptorRequest
and ImportDescriptorResult structs introduced in the previous commit.

Add ProcessDescriptorsImport() to handle wallet locking and rescanning
over a vector of ImportDescriptorRequest items.

Add ProcessUniValueDescriptor() to translat from the UniValue arguments
used by the importdescriptors RPC into ImportDescriptorRequest.

This allows a next commit to extract ImportDescriptor() and
ProcessDescriptorsImport() out of the RPC code so they can be used by
other future interfaces.

Lowers the minimum timestamps to 0 instead of 1.
….cpp

Extract ImportDescriptor() and ProcessDescriptorsImport() out of backup.cpp and move it into imports.cpp so other future interfaces
can use them without needing to know about RPC code.

The commit can be reviewed with the --color-moved=dimmed-zebra option for an easier review.
BIP-380 specifies that descriptors can use either ' or h as
the hardened indicator. ParseHDKeypath only supported the former.

This prepares for using ParseHDKeypath with paths extracted
from descriptors which typically use 'h' for shell-escaping convenience.
Introduces WalletDescriptorInfo struct and DescriptorInfoToUniValue() helper
to avoid code duplication when serializing descriptor metadata to UniValue.

Refactors listdescriptors RPC to use the new helper.
Expose descriptor public-key collection that can omit keys observable from spent outputs. This lets encrypted-backup code derive recipient keys from non-observable descriptor xpubs without adding a separate BIP138-specific descriptor API.
Extract and normalize eligible descriptor BIP32 public key expressions for
BIP138 encrypted backups. Extended public keys that are not directly observable
from descriptor spends are converted to x-only keys, while literal pubkeys,
directly observable xpubs, and the BIP341 NUMS point are excluded.

For MuSig2 descriptors, participant xpubs remain eligible because the on-chain
aggregate key does not reveal them. Add a test vector for aggregate derivation
from bare participant xpubs; this should be upstreamed to the BIP vectors.
Add functions to compute the decryption secret and individual secrets
as specified in BIP138. The decryption secret is derived from sorted
public keys, and individual secrets allow any keyholder to decrypt.

Functions added:
- ComputeDecryptionSecret(): Hash sorted keys to derive decryption secret
- ComputeIndividualSecret(): Hash a single key to derive its individual secret
- ComputeAllIndividualSecrets(): Compute XOR'd secrets for all keys

Includes test vectors from the BIP specification.
Add functions for encoding and decoding BIP138 derivation paths as
count-prefixed arrays of big-endian child indices.

Encode paths in deterministic lexicographic order. Use the existing
ParseHDKeypath helper for textual path parsing in tests.

Includes test vectors from the BIP specification.
Add functions for encoding/decoding individual secrets as specified in BIP138:
- EncodeIndividualSecrets: encode sorted secrets to binary format
- DecodeIndividualSecrets: decode from binary format

Individual secrets allow any keyholder to derive the decryption secret using:
decryption_secret = ci XOR sha256("BIP_XXXX_INDIVIDUAL_SECRET" || pi)

Includes test vectors from the BIP specification.
Add functions for encoding and decoding BIP138 content type metadata.

Content types define what kind of data is in the encrypted payload:
- TYPE 0x01 encodes a 2-byte big-endian BIP number.
- TYPE 0x02 encodes vendor-specific content with CompactSize length.

Decode unknown TYPE values below 0x80 by skipping their length-prefixed
data. Reject TYPE 0x00 as reserved and TYPE values 0x80 or above as
unsupported upgrade-required content.
Add the BIP138 ChaCha20-Poly1305 algorithm identifier, nonce size, tag
size, and test coverage for the AEAD operation used by encrypted
backups.

The tests exercise a random roundtrip and the BIP test vectors against
the existing AEADChaCha20Poly1305 helper with an empty AAD.
Add binary and base64 encoding, backup creation, and decryption helpers
for BIP138 encrypted backups.

Encrypted payloads are encoded as CONTENT followed by CompactSize
plaintext length and plaintext inside the ciphertext. CONTENT is a
single metadata field describing the plaintext.

Add full-backup test vectors and roundtrip tests covering encode,
decode, descriptor-derived encryption, descriptor-derived decryption,
base64 encoding, and wrong-key failure.
Adds two new commands to bitcoin-wallet tool:

encryptbackup:
  Creates an encrypted backup of all wallet descriptors.
  Outputs base64-encoded backup to stdout.
  Requires: -wallet=<name>

decryptbackup:
  Decrypts a backup using a provided extended public key (xpub/tpub).
  Reads base64 backup from stdin, outputs JSON to stdout.
  Requires: -pubkey=<xpub>
  The output is compatible with the importdescriptors RPC.

The decryption only requires an xpub that was used in the original
wallet. In a recovery scenario, the user derives this from their
seed phrase at a known derivation path (e.g., m/84'/0'/0').

Includes functional test demonstrating the full roundtrip.
Display unencrypted metadata from a BIP-xxxx encrypted backup:
- Format version
- Number of recipients
- Encryption algorithm
- Derivation paths (if present)
To include derivation path in backup header.
When walking the CONTENT/LENGTH/PLAINTEXT items of a decrypted payload,
stop at the first 0x00 TYPE byte and treat the remaining bytes as padding,
rather than rejecting the payload. This lets encoders zero-fill a payload up
to a padding bucket (BIP138 "Padding") without breaking decryption.

This is kept as a separate commit because the BIP138 text is not yet
unambiguous: the content-type table still lists 0x00 as "reject", while the
payload section describes the first 0x00 as the end of the item sequence.
See bitcoin/bips#1951 for the proposed clarification.
Other malformed content (e.g. an unknown TYPE >= 0x80) is still rejected.
@Sjors Sjors force-pushed the wip-encrypted-backup branch from d866388 to 6467f0c Compare June 30, 2026 17:50
@Sjors

Sjors commented Jun 30, 2026

Copy link
Copy Markdown
Owner Author

Rebased and see bitcoin/bips#1951 (comment).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants