Skip to content

WIP: implemented encrypted backup BIP138 - #109

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

WIP: implemented encrypted backup BIP138#109
Sjors wants to merge 26 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 9fa12f79b139f3cdb649c6c9a6f79cd1199f4259 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 65c4a4e4777eef41858ca5838bba506f0be320e1 in favor of some trivial serialization code in encryptedbackup.cpp.

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

I also cleaned up 17a25e655d7edf5a97042b6e4ea1c4431477a144 to make better use of existing crypto code (8330771899881c81c4eca8933103064df5af1d89 + e57aae0aa151bf2e3c6cc7398c6fdce174715815).

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 8330771899881c81c4eca8933103064df5af1d89 -> f3c5471068cd3b5a6d64015da1a6f95fba862f53 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

f3c5471068cd3b5a6d64015da1a6f95fba862f53 -> f2482dc30edbd8d78d2a3a4560571e3ce02add37 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
polespinasa and others added 6 commits July 15, 2026 13:52
….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.
@Sjors
Sjors force-pushed the wip-encrypted-backup branch from 6467f0c to 2ac242c Compare July 15, 2026 12:01
@Sjors

Sjors commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Rebased after bitcoin#35579 and bitcoin#32489.

Sjors added 5 commits July 15, 2026 19:41
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.
@Sjors
Sjors force-pushed the wip-encrypted-backup branch from 2ac242c to 3f8b7ae Compare July 15, 2026 17:48
@Sjors

Sjors commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Synced up with BIP138 as of bitcoin/bips@090fbf0.

@Sjors
Sjors force-pushed the wip-encrypted-backup branch from 3f8b7ae to 62b1d81 Compare July 15, 2026 18:26
Sjors added 13 commits July 16, 2026 12:42
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.
Two privacy refinements from the finalized spec, folded together because the
full-backup test vectors exercise both at once and because the decoy padding
is what makes the individual-secret count untrustworthy:

- Pad the individual secrets with random decoys up to a bucket boundary (5,
  10, 20, ..., saturating at the 255 count limit), hiding the exact number of
  real recipients. Decoders cannot tell decoys from real entries; they simply
  try each until one decrypts. Because the count now includes decoys, report
  it as "individual_secrets" rather than "recipients" in the metadata struct
  and the inspectbackup output.

- Omit common derivation paths (m/44h|49h|84h|86h|87h/<coin>h/<account>h and
  m/48h/<coin>h/<account>h/1h|2h) from the header, since recovery
  implementations try these automatically. Add IsCommonDerivationPath() and
  CommonDerivationPaths() helpers.

Test vectors synced with the reference implementation (bitcoin/bips#1951).
BIP138 requires making the user aware of every key expression excluded from
the encryption key set (literal pubkeys and observable xpubs), since the
cosigner holding that key cannot decrypt the backup. Collect the excluded
expressions in ExtractKeysFromDescriptor and refuse to create the backup,
naming them in the error.

Refusing is stricter than the BIP, which allows creating the backup after a
warning; there is no warning channel from bitcoin-wallet and a backup a
cosigner silently cannot recover from is exactly what the requirement guards
against. The NUMS point is exempt, since no cosigner holds it.
Decoy padding is a BIP138 SHOULD, not a MUST. Compact backups are meant to be
small (for QR codes, or to transcribe by hand from paper), so pass
decoys=false when -compact is set. A compact single-recipient backup then
carries one individual secret instead of five.
decryptbackup previously required -pubkey. When -wallet is provided, derive
candidate decryption keys from the wallet's own descriptors instead: try each
descriptor's root xpub directly (covering imported account-level xpubs), and,
where the private key is known, derive account keys at the backup's path
hints and the BIP138 common derivation paths. This lets a cosigner recover a
multisig backup with only their seed and the backup blob.

-pubkey is now optional when -wallet is given.
@Sjors
Sjors force-pushed the wip-encrypted-backup branch from 62b1d81 to f2646bc Compare July 16, 2026 12:05
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