Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

StaticBit Wallet Backup Decoder

A single web page that opens a StaticBit wallet backup and shows the keys inside it.

It exists for one situation: the wallet app is gone, or broken, or will not start, and all you have left is the backup you made. Nothing about that situation should depend on us still being here, so the decoder is one HTML file with no build step, no server, no dependency to install and no network access of any kind. Download it, open it in a browser, read your keys.

The page: index.html in this repository, and the copy published from it on GitHub Pages.


If you need your keys back

1. Save the page first, then disconnect

Please do this rather than typing your password into a page you are viewing online:

  1. Open the page and save it to disk (Ctrl+S, or right-click and "Save page as"). One file lands on your disk. That is the whole program.
  2. Copy that file to a USB stick and carry it to a computer that is not on any network - Wi-Fi off, cable out.
  3. Open the file there by double-clicking it.

The page works exactly the same offline. That is the point of it being one file: there are no fonts, stylesheets, scripts or images for it to fetch, so nothing is missing when there is no network to fetch from.

This is a precaution about your machine and your browser, not about this page. The page itself sends nothing anywhere, and the next section tells you how to check that for yourself rather than take our word for it.

2. Give it your backup

The wallet can hand you a backup in three shapes, and the page reads all three. Pick the one that matches what you have:

You have Choose Notes
A .sbwallet file Backup file The file the wallet wrote when you saved a backup. Drag it onto the page, or click to browse.
A photo or screenshot of the backup QR code QR code picture PNG, JPEG, WebP or GIF. You can also paste a picture straight from the clipboard with Ctrl+V.
The text scanned out of that QR code QR code text A long run of capitals and digits starting with TNA11B. Line breaks introduced by copying are ignored.

A screenshot of the wallet's QR screen is the easiest thing for the page to read. A photograph works too, as long as the whole code is in frame, in focus and not at a steep angle.

3. Type the backup password

The password is the one you chose when you made the backup. It is not your wallet passcode, and it is not recoverable: nothing in the backup file, and nobody anywhere, can turn a forgotten backup password back into your keys. That is the property that makes a backup safe to keep.

Leave the field empty for a backup made without a password. Those exist only as an on-screen QR code, never as a file.

4. Read the result

Each wallet in the backup comes out as an address, a key type, the secret itself, and whatever optional fields were stored with it: a mnemonic passphrase, a derivation path, a master address, a description. The secret is everything an XRPL wallet needs to import the account - this wallet, or any other software that speaks XRPL.

Treat what is now on your screen the way you would treat the words on a seed card. Close the tab when you are done, and clear whatever you copied out of it.

If it will not open

The page never just says "error". Every refusal names what failed and, importantly, says whether your password was even involved. The common ones:

  • "That password did not open this backup." The bytes decrypted to something that failed the authentication check. Almost always a wrong password: check the keyboard layout, check Caps Lock, try an older password you might have used. Rarely it means a byte of the file has been altered - but suspect the password first.
  • "This backup needs a password." The container is encrypted and the password field was empty.
  • "This is not a StaticBit backup." The file does not begin with the four bytes SBW1. Nothing was decrypted and your password was never used, so retyping it will not help. A backup is the .sbwallet file itself - not a screenshot of the app, not an exported JSON, not a key file from another wallet.
  • "No QR code was found in this picture." The picture loaded, but no code could be made out in it. Retake it with the whole code in frame and in focus; glare across a printed code does this too. A screenshot beats a photograph every time.
  • "The contents are damaged." The password was accepted - the authentication tag verified, so the bytes are exactly the ones that were written - but what came out is not a wallet list. Your password is not the problem here.

If a backup file will not open and you have another copy of it, try the other copy. A single flipped bit anywhere in the file is enough to make it refuse, by design.


Why you can trust this page

You do not have to. Everything below is written so that it can be checked, and every claim has a way to falsify it.

It is one file, and you can read it

index.html is the entire program: markup, stylesheet and script in one document, deliberately not minified. Open it in any text editor, or use your browser's "View source". The code that decodes your backup is a few hundred lines, with comments explaining what each field of the container is and why.

It loads nothing and sends nothing

There is no <script src>, no <link rel=stylesheet>, no @import, no web font, no image URL, no fetch, no XMLHttpRequest, no WebSocket and no navigator.sendBeacon anywhere in the file. Search for those strings yourself; the test suite in this repository does exactly that on every run.

The page also enforces this on itself, in its own <head>:

Content-Security-Policy:
  default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
  img-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'

default-src 'none' denies every resource the page could load. connect-src 'none' denies every outbound connection it could open - requests, sockets and beacons alike. So even a future edit that tried to send your keys somewhere would be stopped by the browser, rather than by our good intentions. There is deliberately no 'unsafe-eval', no external origin and no CDN; the two 'unsafe-inline' entries are what let a single file carry its own script and stylesheet.

If you want to watch it happen: open the browser's developer tools on the Network tab, decode a backup, and see that the request list stays empty.

It remembers nothing

No cookies, no localStorage, no sessionStorage, no IndexedDB, no writes to the URL. Closing the tab is all the cleanup there is.

The cryptography is the browser's, not ours

Key derivation, decryption and decompression are done by the browser's own WebCrypto (PBKDF2, AES-GCM) and DecompressionStream. This page contains no hand-rolled cipher. What it does contain is the container parsing: which bytes are the salt, which are the nonce, and what goes in as associated data.

One piece of third-party code, named and checksummed

To read a QR code out of a picture the page prefers the browser's own BarcodeDetector. Firefox and Safari do not have it, so for those browsers the page carries a copy of jsQR 1.4.0 (Apache License 2.0, https://github.com/cozmo/jsQR) inside itself - unminified, with its license reproduced in full, its provenance, and the checksum of the published file it was copied from written out immediately above it in the source.

Verify that copy against upstream yourself:

npm pack jsqr@1.4.0
tar xzf jsqr-1.4.0.tgz
sha256sum package/dist/jsQR.js
# bc40c8a15196236b2314db0856f72ca0b49980cd5413b8c852a7349f5fee0859

Compare that checksum with the one written in index.html, and diff the file against the bytes between the BEGIN/END markers in the backup-decoder-qr-vendor block. The test suite recomputes the same checksum on every run and fails if the vendored bytes have moved by one byte.

The test suite tests the shipped file, not a copy of it

run-tests.mjs does not contain a second implementation of the decoder. It pulls the script blocks out of index.html verbatim and executes those exact bytes under Node, so what passes there is what a browser runs.

node run-tests.mjs

No dependencies and no npm install; it needs Node 18 or newer, for DecompressionStream. It checks, among other things: that real containers produced by the wallet's own packer open correctly; that a wrong password, a tampered header byte, a tampered ciphertext byte, a foreign file and a decompression bomb are each refused with their own distinct message; that a QR code is read back out of a generated picture; and the page-level claims above - no outbound anything, no storage, no eval, the CSP as quoted, the source unminified.

Every vector the suite needs is committed here, so the whole run happens from a clone of this repository and nothing reports as skipped. That includes vectors/known.sbwallet - the frozen container the wallet's own BackupContainerTests opens - which is what turns "this page parses a format" into "this page opens files the wallet actually wrote". If that file is missing the run stops with an error instead of quietly making two fewer checks: a compatibility check that skips itself is a green run that proves nothing.

SBWALLET_KNOWN_VECTOR moves where that one vector is read from, for checking a copy against another. It cannot switch the checks off - a path that does not exist is an error too. A run from inside a wallet checkout additionally compares the committed copy against the wallet's own and fails if the two have drifted apart.


The backup container format

Enough detail to write your own decoder without reading ours. All of it is derived from the wallet's packer rather than from a design document, so this is what real files actually contain.

Layout

All multi-byte integers are big-endian.

"SBW1" (4) | version (1) | kdfId (1) | iterations (4) | salt (16) | nonce (12) | ciphertext (N) | tag (16)
Field Size Meaning
magic 4 ASCII SBW1, bytes 53 42 57 31
version 1 Currently 1
kdfId 1 0 means no password; any other value means a password-protected container
iterations 4 PBKDF2 iteration count. Present only when kdfId != 0
salt 16 PBKDF2 salt. Present only when kdfId != 0
nonce 12 AES-GCM nonce
ciphertext N The encrypted, compressed payload
tag 16 AES-GCM authentication tag, after the ciphertext

The associated data for AES-GCM is the whole header - everything before the nonce, which is 26 bytes (4 + 1 + 1 + 4 + 16) for a password-protected container. That is what makes a substituted kdfId, version byte or salt byte detectable, instead of silently changing the result.

In WebCrypto the tag is passed to decrypt together with the ciphertext as a single buffer, so ciphertext | tag goes in as it stands - there is no need to split it.

The password-less form

When kdfId = 0 the header is just "SBW1" | version | 0 (6 bytes) and the compressed payload follows it immediately: there is no iterations, no salt, no nonce and no tag at all. Nothing is encrypted, so nothing is authenticated either. The wallet only ever produces this form as a QR code shown on screen, never as a file.

Cryptography

Key derivation PBKDF2-HMAC-SHA256
Key length 32 bytes
Iterations read from the container's own iterations field, never from a constant
Salt the salt field, 16 bytes
Password to bytes UTF-8, no normalization
Cipher AES-256-GCM
Nonce 12 bytes
Tag 16 bytes, after the ciphertext

Reading the iteration count out of the file rather than assuming one is not optional: containers exist at several counts, and a decoder that hard-codes one fails on the others in a way that looks exactly like a wrong password.

Compression

Raw DEFLATE (RFC 1951), with no zlib wrapper. In .NET that is DeflateStream; in a browser it is DecompressionStream('deflate-raw') and not 'deflate' - the latter expects a zlib header and fails on this stream.

Packing order is JSON -> UTF-8 -> deflate -> encrypt. Unpacking is the reverse.

Bound the inflated size. A few kilobytes of hostile input can otherwise expand into gigabytes; this page refuses anything that wants to grow past about 4 MB, which is 1000 wallets of 4 KiB each plus the envelope.

Payload

JSON, camelCase field names, no indentation:

{"wallets":[{"keyValue":"...","classicAddress":"r...","keyType":0,"algorithm":"..."}]}

Fields of a wallet entry, in declaration order: keyValue, mnemonicPassphrase, classicAddress, keyType, algorithm, derivationPath, masterAddress, description, folder, destinationTag. A field with no value is left out rather than written as null, so most wallets carry only the first four; this page treats a missing key and an explicit null the same way. keyValue, classicAddress, keyType and algorithm are required; the rest may be null.

keyValue is the secret itself - a seed, a mnemonic or a secret key, depending on keyType.

Base45, for QR codes only

The contents of a backup QR code are the container encoded with Base45 (RFC 9285), not raw bytes: both platform scanners hand back a string, and bytes above 0x7F do not survive that.

The alphabet, index equal to value:

0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:

(36 is a space, then $ % * + - . / :)

Encoding: a pair of bytes n = b0 * 256 + b1 is written as three characters, where n = c0 + c1*45 + c2*45^2 - least significant digit first. A single trailing byte is written as two characters in the same order. The encoded length is always a multiple of 3, except for an odd byte count, where the remainder is 2.

Because every container starts with the same bytes, every StaticBit backup code starts with the same characters: the pair S B (0x5342 = 21314) encodes as T N A, and the version and kdfId bytes that follow produce the familiar TNA11B prefix. That is a useful "this looks like one of ours" check and nothing more - the real check is parsing the header.


Repository layout

index.html        the decoder - the whole program, and the only file that gets published
run-tests.mjs     the test runner; extracts and executes the script blocks out of index.html
vectors/          backup containers produced by the wallet's own packer, plus their manifest
vector-gen/       the C# generator that writes vectors/ (needs a checkout of the wallet)
vendor/           qrcode-generator, used by the tests to build a QR picture to read back

Only index.html is published to GitHub Pages. The tests, the vectors and the generator stay in the repository and never become part of the deployed page - a visitor has no use for them, and backup containers have no business being fetchable from the same origin as the decoder. They stay in the repository because that is where they do their work: publication is gated on the test run, so a decoder that cannot open the vectors does not get published at all.

The vectors

vectors/ holds real containers, not fixtures written by hand to match this page's own idea of the format. They come in two kinds.

known.sbwallet is frozen. It is the same file the wallet's BackupContainerTests opens, byte for byte, and it is the compatibility claim in this repository: a container the wallet packed, opened here. It is never regenerated - a regenerated "frozen" vector would stop being evidence of anything - and it is copied, not derived, when the wallet's copy is touched.

The rest are regenerated by vector-gen, which packs them with the wallet's own BackupContainer rather than a second implementation of the format; a vector produced by a reimplementation would only prove that two of our decoders agree with each other. That needs a checkout of the wallet and is done by hand when the container format changes:

dotnet run --project vector-gen

The generated files are committed, so nobody needs a wallet checkout to run the tests.

What is inside the vectors

Nothing real. Every key, address, description and password in them was made up for the tests, and says so in its own text: seeds read sEdV1DecoderVectorSeedOne1111111, addresses read rDecoderVectorAddressOne111111111, and the passwords are correct horse battery staple and пароль-Ω-🔐-ok. None of those strings is a decodable XRPL seed or address (they fail base58check) and none corresponds to an account on any network. They are published deliberately, because they are what lets somebody who does not trust this page check that it opens the real format.


License

StaticBit's own code here is released under the MIT license - see LICENSE.

That covers our code and only our code. index.html also contains a verbatim copy of jsQR 1.4.0, copyright Cosmo Wolfe, Jefff Nelson and the jsQR contributors, which is under the Apache License 2.0 and stays there: somebody else's work cannot be relicensed by putting it in our file. Its full license text, its provenance and its checksum are reproduced inside index.html immediately above the vendored code, as section 4(a) of that license requires. Anything that redistributes the page - including saving it and handing the file to somebody else - carries that notice along with it automatically, because the notice lives inside the file.

Section 4(d) of the Apache license, which carries forward the contents of a NOTICE file, does not apply: jsQR ships no NOTICE. Its repository root has a LICENSE and nothing else of the kind, so the reproduced license text and attribution are the whole of the obligation.

MIT and Apache-2.0 are compatible in this direction - the page may be redistributed under the MIT terms for our part while the vendored block keeps its own - and the vendored block's notice stays in the file either way.

vendor/qrcode-generator-1.4.4.js is a copy of QR Code Generator for JavaScript, copyright 2009 Kazuhiko Arase, under the MIT license, with its notice intact at the top of the file. It is used only by the test suite - to draw a picture of a QR code that the page is then asked to read back - and is not part of index.html or of the published page.

About

Offline decoder for StaticBit Wallet backups. One HTML file, no dependencies, no network. Open it from disk and read your own keys.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages