Skip to content

Commit adc73bb

Browse files
committed
bip encrypted_backup
1 parent 2e3dd3f commit adc73bb

9 files changed

Lines changed: 1302 additions & 0 deletions

bip-encrypted-backup.md

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
```
2+
BIP: ?
3+
Title: Compact encryption scheme for non-seed wallet data
4+
Author: Pyth <pyth@pythcoiner.dev>
5+
Comments-URI: https://github.com/bitcoin/bips/wiki/Comments:BIP-xxxx
6+
Status: Draft
7+
Type: Specification
8+
Created: 2025-08-22
9+
License: BSD-2-Clause
10+
Post-History: https://delvingbitcoin.org/t/a-simple-backup-scheme-for-wallet-accounts/1607/31
11+
https://groups.google.com/g/bitcoindev/c/5NgJbpVDgEc
12+
```
13+
14+
## Introduction
15+
16+
### Abstract
17+
18+
This BIP defines a compact encryption scheme for **wallet descriptors** (BIP-0380),
19+
**wallet policies** (BIP-0388), **labels** (BIP-0329), and **wallet backup metadata** (json).
20+
The payload must not contain any private key material.
21+
22+
Users can store encrypted backups on untrusted media or cloud services without leaking
23+
addresses, script structures, or cosigner counts. The encryption key derives from the
24+
lexicographically-sorted public keys in the descriptor, allowing any keyholder to decrypt
25+
without additional secrets.
26+
27+
Though designed for descriptors and policies, the scheme works equally well for labels
28+
and backup metadata.
29+
30+
### Copyright
31+
32+
This BIP is licensed under the BSD 2-Clause License.
33+
Redistribution and use in source and binary forms, with or without modification, are
34+
permitted provided that the above copyright notice and this permission notice appear
35+
in all copies.
36+
37+
### Motivation
38+
39+
Losing the **wallet descriptor** (or **wallet policy**) is just as catastrophic as
40+
losing the seed itself. The seed lets you sign, but the descriptor maps you to your coins.
41+
For multisig or miniscript wallets, keys alone won't help—without the descriptor, you
42+
can't reconstruct the script.
43+
44+
Offline storage of descriptors has two practical obstacles:
45+
46+
1. **Descriptors are hard to store offline.**
47+
Descriptors can be much longer than a 12/24-word seed. Paper and steel backups
48+
become impractical or error-prone.
49+
50+
2. **Online redundancy carries privacy risk.**
51+
USB drives, phones, and cloud storage solve the length problem but expose your
52+
wallet structure. Plaintext descriptors leak your pubkeys and script details.
53+
Cloud encryption doesn't help against subpoenas or provider breaches, and each
54+
copy increases attack surface.
55+
56+
These constraints lead to an acute need for an **encrypted**, and
57+
ideally compact backup format that:
58+
59+
* can be **safely stored in multiple places**, including untrusted on-line services,
60+
* can be **decrypted only by intended holders** of specified public keys,
61+
62+
See the original [Delving post](https://delvingbitcoin.org/t/a-simple-backup-scheme-for-wallet-accounts/1607/31)
63+
for more background.
64+
65+
### Expected properties
66+
67+
* **Encrypted**: safe to store with untrusted cloud providers or backup services
68+
* **Access controlled**: only designated cosigners can decrypt
69+
* **Easy to implement**: it should not require any sophisticated tools/libraries.
70+
* **Vendor-neutral**: works with any hardware signer
71+
72+
### Scope
73+
74+
This proposal targets wallet descriptors (BIP-0380) and policies (BIP-0388), but the
75+
scheme also works for labels (BIP-0329) and other wallet metadata like
76+
[wallet backup](https://github.com/pythcoiner/wallet_backup).
77+
78+
Private key material MUST be removed before encrypting any payload.
79+
80+
## Specification
81+
82+
Note: in the followings sections, the operator ⊕ refers to the bitwise XOR operation.
83+
84+
### Secret generation
85+
86+
- Let $p_1, p_2, \dots, p_n$, be the public keys in the descriptor/wallet policy, in increasing lexicographical order
87+
- Let $s$ = sha256("BEB_BACKUP_DECRYPTION_SECRET" | $p_1$ | $p_2$ | ... | $p_n$)
88+
- Let $s_i$ = sha256("BEB_BACKUP_INDIVIDUAL_SECRET" | $p_i$)
89+
- Let $c_i$ = $s$ ⊕ $s_i$
90+
91+
**Note:** To prevent attackers from decrypting the backup using publicly known
92+
keys, explicitly exclude any public keys with x coordinate
93+
`50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0` (the BIP341 NUMS
94+
point, used as a taproot internal key in some applications). Additionally, exclude any
95+
other publicly known keys. In some cases, it may be possible to exclude certain keys
96+
from this process for customs applications or user needs, it is recommended to document
97+
such decision.
98+
99+
100+
101+
### AES-GCM Encryption
102+
103+
* let $nonce$ = random()
104+
* let $ciphertext$ = aes_gcm_256_encrypt($payload$, $secret$, $nonce$)
105+
106+
### AES-GCM Decryption
107+
108+
In order to decrypt the payload of a backup, the owner of a certain public key p
109+
computes:
110+
111+
* let $s_i$ = sha256("BEB_BACKUP_INDIVIDUAL_SECRET" ‖ $p$)
112+
* for each `individual_secret_i` generate `reconstructed_secret_i` =
113+
`individual_secret_i``si`
114+
* for each `reconstructed_secret_i` process $payload$ =
115+
aes_gcm_256_decrypt($ciphertext$, $secret$, $nonce$)
116+
117+
Decryption will succeed if and only if **p** was one of the keys in the
118+
descriptor/wallet policy.
119+
120+
### Encoding
121+
122+
The encrypted backup must be encoded as follows:
123+
124+
`MAGIC` `VERSION` `DERIVATION_PATHS` `INDIVIDUAL_SECRETS` `ENCRYPTION`
125+
`ENCRYPTED_PAYLOAD`
126+
127+
#### Magic
128+
129+
`MAGIC`: 3 bytes which are ASCII/UTF-8 representation of **BEB** (`0x42, 0x45,
130+
0x42`).
131+
132+
#### Version
133+
134+
`VERSION`: 1 byte unsigned integer representing the format version. The current
135+
specification defines version `0x01`.
136+
137+
#### Derivation Paths
138+
139+
Note: the derivation-path vector should not contain duplicates.
140+
Derivation paths are optional; they can be useful to simplify the recovery process
141+
if one has used a non-common derivation path to derive his key.
142+
143+
`DERIVATION_PATH` follows this format:
144+
145+
`COUNT`
146+
`CHILD_COUNT` `CHILD` `...` `CHILD`
147+
`...`
148+
`CHILD_COUNT` `CHILD` `...` `CHILD`
149+
150+
`COUNT`: 1-byte unsigned integer (0–255) indicating how many derivation paths are
151+
included.
152+
`CHILD_COUNT`: 1-byte unsigned integer (1–255) indicating how many children are in
153+
the current path.
154+
`CHILD`: 4-byte big-endian unsigned integer representing a child index per BIP-32.
155+
156+
#### Individual Secrets
157+
158+
At least one individual secret must be supplied.
159+
160+
The `INDIVIDUAL_SECRETS` section follows this format:
161+
162+
`COUNT`
163+
`INDIVIDUAL_SECRET`
164+
`INDIVIDUAL_SECRET`
165+
166+
`COUNT`: 1-byte unsigned integer (1–255) indicating how many secrets are included.
167+
`INDIVIDUAL_SECRET`: 32-byte serialization of the derived individual secret.
168+
169+
Note: the individual secrets vector should not contain duplicates. Implementations
170+
MAY deduplicate secrets during encoding or parsing.
171+
172+
#### Encryption
173+
174+
`ENCRYPTION`: 1-byte unsigned integer identifying the encryption algorithm.
175+
176+
| Value | Definition |
177+
|:-------|:---------------------------------------|
178+
| 0x00 | Undefined |
179+
| 0x01 | AES-GCM-256 |
180+
181+
#### Payload Size Limits
182+
183+
AES-GCM-256 (per RFC5116) supports plaintext up to 2^36 - 31 bytes.
184+
Implementations MAY impose stricter limits based on platform constraints
185+
(e.g., limiting to 2^32 - 1 bytes on 32-bit architectures).
186+
187+
Implementations MUST reject empty payloads.
188+
189+
#### Ciphertext
190+
191+
`CIPHERTEXT` is the encrypted data resulting encryption of `PAYLOAD` with algorithm
192+
defined in `ENCRYPTION` where `PAYLOAD` is encoded following this format:
193+
194+
`CONTENT` `PLAINTEXT`
195+
196+
#### Content
197+
198+
`CONTENT` is a variable length field defining the type of `PLAINTEXT` being encrypted,
199+
it follows this format:
200+
201+
`LENGTH` `VARIANT`
202+
203+
`LENGTH`: 1-byte unsigned integer representing the length of `VARIANT` content.
204+
`VARIANT`: there is 3 variants:
205+
- if `LENGTH` == 0, it represent undefined content, no `VARIANT` follow.
206+
- if `LENGTH` == 2, `VARIANT` is 2-byte big-endian unsigned integer representing
207+
the related BIP number that defines the exact content category.
208+
- if 2 < `LENGTH` < 0xFF, `VARIANT` is `LENGTH` additional bytes carrying opaque,
209+
vendor-specific data.
210+
211+
Note: `LENGTH` = 0xFF is reserved for future extensions. Parsers MUST reject
212+
payloads with `LENGTH` = 0xFF by returning an error.
213+
214+
#### Encrypted Payload
215+
216+
`ENCRYPTED_PAYLOAD` follows this format:
217+
218+
`NONCE` `LENGTH` `CIPHERTEXT`
219+
220+
221+
`NONCE`: 12-byte nonce for AES-GCM-256.
222+
`LENGTH`: [compact
223+
size](https://en.bitcoin.it/wiki/Protocol_documentation#Variable_length_integer)
224+
integer representing ciphertext length.
225+
`CIPHERTEXT`: variable-length ciphertext.
226+
227+
Note: `CIPHERTEXT` is followed by the end of the `ENCRYPTED_PAYLOAD` section.
228+
Compliant parsers MUST stop reading after consuming `LENGTH` bytes of ciphertext;
229+
additional trailing bytes are reserved for vendor-specific extensions and MUST
230+
be ignored.
231+
232+
## Rationale
233+
234+
- Why derivation paths are optional: When standard derivation paths are used, they are
235+
easily discoverable, making them straightforward to brute-force. Omitting them
236+
enhances privacy by reducing the information shared publicly about the descriptor
237+
scheme.
238+
239+
- Why avoid including fingerprints in plaintext encoding: Including fingerprints leaks
240+
direct information about the descriptor participants, which compromises privacy.
241+
242+
243+
### Future Extensions
244+
245+
The version field enables possible future enhancements:
246+
247+
- Additional encryption algorithms
248+
- Support for threshold-based decryption
249+
- Hiding number of participants
250+
- bech32m export
251+
252+
### Implementation
253+
254+
- Rust [implementation](https://github.com/pythcoiner/bitcoin-encrypted-backup)
255+
256+
### Test Vectors
257+
258+
[key_types.json](./bip-encrypted-backup/test_vectors/keys_types.json) contains test
259+
vectors for key serialisations.
260+
[content_type.json](./bip-encrypted-backup/test_vectors/content_type.json) contains test
261+
vectors for contents types serialisations.
262+
[derivation_path.json](./bip-encrypted-backup/test_vectors/derivation_path.json) contains
263+
test vectors for derivation paths serialisations.
264+
[individual_secrets.json](./bip-encrypted-backup/test_vectors/individual_secrets.json)
265+
contains test vectors for individual secrets serialization.
266+
[encryption_secret.json](./bip-encrypted-backup/test_vectors/encryption_secret.json)
267+
contains test vectors for generation of encryption secret.
268+
[aesgcm256_encryption.json](./bip-encrypted-backup/test_vectors/aesgcm256_encryption.json)
269+
contains test vectors for ciphertexts generated using AES-GCM256.
270+
[encrypted_backup.json](./bip-encrypted-backup/test_vectors/encrypted_backup.json)
271+
contains test vectors for generation of complete encrypted backup.
272+
273+
## Acknowledgements
274+
275+
// TBD
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[
2+
{
3+
"description": "Basic encryption with short plaintext",
4+
"nonce": "000102030405060708090a0b",
5+
"plaintext": "48656c6c6f",
6+
"secret": "0000000000000000000000000000000000000000000000000000000000000000",
7+
"ciphertext": "c0ae5f3e6f609000697cc7c8de2b30ce8817ca44fa"
8+
},
9+
{
10+
"description": "Empty plaintext should fail",
11+
"nonce": "000102030405060708090a0b",
12+
"plaintext": "",
13+
"secret": "0000000000000000000000000000000000000000000000000000000000000000",
14+
"ciphertext": null
15+
},
16+
{
17+
"description": "Encryption with all zeros",
18+
"nonce": "000000000000000000000000",
19+
"plaintext": "00000000000000000000000000000000",
20+
"secret": "0000000000000000000000000000000000000000000000000000000000000000",
21+
"ciphertext": "cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919"
22+
},
23+
{
24+
"description": "Encryption with all FFs",
25+
"nonce": "ffffffffffffffffffffffff",
26+
"plaintext": "ffffffffffffffffffffffffffffffff",
27+
"secret": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
28+
"ciphertext": "42c4417ae76f276beb09973a4b9b37155b3f5fe9af300dd8d2372023367d86b7"
29+
},
30+
{
31+
"description": "Longer plaintext",
32+
"nonce": "0f1e2d3c4b5a69788796a5b4",
33+
"plaintext": "546869732069732061206c6f6e67657220706c61696e746578742074686174207368756c6420626520656e637279707465642070726f7065726c792e",
34+
"secret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
35+
"ciphertext": "ea2e3d6ac4724e3301f138b449495b9eed1f01207eb5f62d1c0e103f2237a8e459b1770a8c7b8eabf2d69922e5f767ad4de4d8d7bf737e49dd6fef6d7996158207af0edd60e87faf8a353d7c"
36+
}
37+
]
38+
39+
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
[
2+
{
3+
"description": "None",
4+
"valid": true,
5+
"content": "00"
6+
},
7+
{
8+
"description": "Bip 380",
9+
"valid": true,
10+
"content": "02017c"
11+
},
12+
{
13+
"description": "Bip 388",
14+
"valid": true,
15+
"content": "020184"
16+
},
17+
{
18+
"description": "Bip 329",
19+
"valid": true,
20+
"content": "020149"
21+
},
22+
{
23+
"description": "Bip 999",
24+
"valid": true,
25+
"content": "0203e7"
26+
},
27+
{
28+
"description": "Bip max",
29+
"valid": true,
30+
"content": "02ffff"
31+
},
32+
{
33+
"description": "Bip min",
34+
"valid": true,
35+
"content": "020000"
36+
},
37+
{
38+
"description": "Propietary 00010203",
39+
"valid": true,
40+
"content": "0400010203"
41+
},
42+
{
43+
"description": "Invalid BIP",
44+
"valid": false,
45+
"content": "0200"
46+
},
47+
{
48+
"description": "Invalid proprietary",
49+
"valid": false,
50+
"content": "0300"
51+
},
52+
{
53+
"description": "Invalid proprietary",
54+
"valid": false,
55+
"content": "030000"
56+
},
57+
{
58+
"description": "Starting with 0xFF is reserved",
59+
"valid": false,
60+
"content": "ff"
61+
},
62+
{
63+
"description": "Starting with 0xFF is reserved",
64+
"valid": false,
65+
"content": "ff000000"
66+
},
67+
{
68+
"description": "Starting with 0x01 is invalid",
69+
"valid": false,
70+
"content": "01"
71+
},
72+
{
73+
"description": "Starting with 0x01 is invalid",
74+
"valid": false,
75+
"content": "0100"
76+
}
77+
]

0 commit comments

Comments
 (0)