Wallet labels over VSS
A profile · draft 2 · 21 September 2026 · source
How a Bitcoin wallet keeps its BIP-329 labels — and the notes and files that belong with them — on a storage host it does not trust, so that they come back with the wallet from the same material that restores the wallet. First implementation: Any Two Keys (labels.rs), against https://vss.mineracks.com/vss. Any VSS host works; nothing here is specific to ours.
Why VSS
LDK's Versioned Storage Service is a small authenticated key-value store with per-object versions. Clients encrypt before upload. The operator sees an authentication public key, a store id and ciphertext, and nothing that ties them to an address or a transaction. That is the right shape for labels, which say who paid you and why, and there was no need for a new server.
The root secret
Everything derives from one 32-byte root that the wallet can rebuild from its own recovery material and nobody else can.
- Any Two Keys (k-of-n fixed keys on YubiKeys; the seal is Shamir shares of the recovery record, PIN-locked on the cards):
root = HKDF-SHA256(salt = "a2k-labels-v1", ikm = share_1 ‖ … ‖ share_n), expand"root". Any k cards with their PINs rebuild every share; fewer learn nothing. No new secret, no change to the cards. - BIP-39 / BIP-32 wallets (recommended): a dedicated hardened path from the seed, for example
m/329'/0', with 32 bytes of the derived private key as IKM and the same salt. Never the descriptor alone: every public key in a k-of-n script is on chain after the first spend.
From the root, HKDF-Expand with the root as PRK:
| Output | Info | Use |
|---|---|---|
| content key, 32 bytes | "content" |
XChaCha20-Poly1305 for the record object and every file chunk |
| auth key, 32 bytes | "auth" ‖ counter (counter from 0 until a valid secp256k1 scalar) |
signs the VSS requests (LDK signature auth) |
| store id, 16 bytes → 32 hex | "store" |
VSS store_id |
| attachment-naming key, 32 bytes | "att-name" |
names file objects without revealing their hash |
The record object
One VSS object per wallet, key labels:
"A2KL" 0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305(content key, nonce, aad = store id, plaintext)
Plaintext is JSON: {"v":1,"records":{"<type>:<ref>": <record>}}. Keys are BIP-329 type:ref — addr:, tx:, out: for outputs. A record is:
| Field | Meaning |
|---|---|
label |
the BIP-329 label. Empty means deleted: a tombstone, so the deletion travels too |
t |
when the record last changed, unix seconds |
note |
longer text belonging to the same subject (the reference implementation allows 20,000 characters) |
files |
attachments, each {name, mime, size, sha256, t} — see below |
| anything else | carried through untouched |
A record is alive if it has a label, a note or files. A record that is only extras is alive too.
Every implementation must preserve fields it does not understand. A build that drops unknown fields erases them for every other device on its next write. The reference implementation flattens them into the record and writes them back verbatim. This is what lets the format grow without a version negotiation: Any Two Keys puts a ctx object there, written once per transaction, recording which computer made or first saw the payment and — only with the owner's consent — roughly where.
Sync and merge
Get, merge, put with the version just read; on a version conflict, get and merge again. Merge is per record:
- Newest
twins. - Equal
tprefers the live record over its own tombstone. - The winner also inherits any extra field the loser had and it lacks. Write-once facts are not edits, so a label typed on a second computer must not delete where the payment was made. A removal is itself a value and therefore survives.
Two devices editing the same wallet converge without a coordinator, and the host never has to understand the content.
Attachments
A file's bytes are their own objects: att/<name>/<n>, one 256 KiB chunk of plaintext each.
"A2KA" 0x01 ‖ nonce[24] ‖ XChaCha20-Poly1305(content key, nonce, aad = "att/<name>/<n>", chunk)
- The object name is
HMAC-SHA256(attachment-naming key, hex sha256)truncated to 16 bytes, not the hash. A host that knew the hash could test whether you hold a file it already has. - The chunk's own object name is the associated data, so a chunk cannot be moved between files or between positions within a file.
- 256 KiB, not the 1 MiB first planned. The VSS client allows a fixed ten seconds per request, and a 1 MiB chunk did not clear a busy domestic uplink in that time. This size needs about 0.2 Mbit/s.
- Upload is diff-based: one prefix listing tells the client which chunks the host already has, so nothing is sent twice and a chunk that is already there is not an error. It runs in the background once a file is added.
- Download is proved: every chunk must open under its own name, and the reassembled file must hash to the
sha256the record names, or it is discarded. - Deletion removes a file's chunks once no record in the wallet names it.
- Reference limits: 25 MB per file, 250 MB per wallet on the host. Files beyond that stay on the device that made them.
What the host learns
An authentication public key and a store id, both random-looking and unrelated to the wallet's keys. Object sizes and write times. The number of chunks a file has, which bounds its size. It cannot read a label, a note or a file, cannot tell which wallet a store belongs to, cannot test whether you hold a file it has seen elsewhere, and cannot alter anything without the client noticing. Rate limits and body caps are the only abuse brakes, as for any VSS tenant.
Status
Implemented and in use: labels, notes, extra fields, attachments, and the merge rules above, exercised by a live round trip against vss.mineracks.com on every release — two simulated computers converge, a deletion travels, a wrong key opens nothing, and a file goes up, comes back proved and is deleted again.

